前端面试八股文
前端八股文
自己复习的同时,在这记录一下,强化记忆。
javaScript部分
文章目录
- 前端八股文
- javaScript部分
- 1、 JavaScript有哪些数据类型,他们的区别?
- 2、数据类型检测的方式有哪些
- 3、null和 undefined区别
- 4、instanceof操作符实现的原理及实现
- 5、如何获取安全的undefined值?
- 6、Object.is()与比较操作符“\=\==”、“==”的区别?
- 7、什么是JavaScript中的报装类型?
- 8、为什么会有BigInt的提案?
- 9、如何判断一个对象是空对象
- 10、const对象的属性可以修改吗
- 11、如果new一个箭头函数会怎么样?
- 12、箭头函数的this指向哪里
- 13、扩展运算符的作用及使用场景
- 14、proxy可以实现什么功能?
- 15、常用的正则表达式
- 17、JavaScript脚本延迟加载的方式有哪些
- 18、什么是DOM和BOM?
- 19、escape、encodeURI、encodeURIComponent的区别
- 21、什么是尾调用,使用尾调用有什么好处?
- 22、ES6模块与CommonJS模块有什么异同?
- 23、for...in 和 for...of的区别
- 24、ajax、axios、fetch的区别
- 25、对原型、原型链的理解
- 26、原型链的终点是什么?如何打印出原型链的终点?
- 27、对作用域、作用域链的理解
- 28、对this对象的理解
- 29、call() 和 apply() 的区别
- 31、对Promise的理解
- 32、Promisej解决了什么问题
- 33、对async/await的理解
- 34、async/await的优势
- 35、async/await对比Promise的优势
- 36、对象创建的方式有哪些?
- 37、对象继承的方式有哪些?
- 38、哪些情况会导致内存泄漏
1、 JavaScript有哪些数据类型,他们的区别?
JavaScript共有8种数据类型,分别是Undefined、Null、Boolean、Number、String、Object、Symbol、BigInt
- Symbol 代表创建后独一无二不可变的数据类型,它主要是为了解决可能出现的全局变量冲突的问题
- BigInt 是一种数字类型的数据,它可以表示任意精度格式的整数,使用BigInt可以安全地存储和操作大整数,即使这个数已经超出了Number能够表示的安全整数范围。
这些数据可以分为原始数据类型和引用数据类型
- 栈:原始数据类型(undefined、null、boolean、Number、String)
- 堆:引用数据类型(对象、数组和函数)两种数据类型的区别在于存储位置的不同:
- 原始数据类型是直接存储在栈(stack)中的简单数据段,占据空间小、大小固定,属于被频繁使用的数据,所以放入栈中存储。
- 引用数据类型存储在堆(heap)中的对象,占据空间大,大小不固定。如果存储在栈中,将会影响程序运行的性能;引用数据类型在栈中存储了指针,该指针指向堆中该实体的原始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
堆和栈的概念存在于数据结构和操作系统内存中,在数据结构中:
- 在数据结构中,栈中数据的存取方式为先进后出。
- 堆是一个优先队列,是按优先级来进行排序的,优先级可以按照大小来规定。
在操作系统中,内存被分为栈区和堆区:栈内存由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
- 堆内存一般由开发者分配释放,若开发者不释放,程序结束时可能由垃圾回收机制回收
2、数据类型检测的方式有哪些
(1) typeof
console.log(typeof 20); //number console.log(typeof true); //boolean console.log(typeof 'string'); //string console.log(typeof []); //object console.log(typeof function(){}); //function console.log(typeof undefined); //undefined console.log(typeof null); // object
其中数组、对象、null都会被判断为object,其他判断都正确
(2)instanceof
instanceof可以正确判断对象的类型,其内部运行机制是判断在其原型链中能否找到该类型的原型
console.log(20 instanceof Number) //false console.log(true instanceof Boolean) //false console.log('str' instanceof String) //false console.log(function(){} instanceof Function) //true // console.log(undefined instanceof Undefined) //报错Undefined is not defined console.log([] instanceof Array) //true console.log({} instanceof Object) //true // console.log(null instanceof Null) //报错Nullis not defined
可以看到,instanceof只能正确判断引用数据类型,而不能判断基本数据类型。instanceof运算符可以用来测试一个对象在其原型链中是否存在一个构造函数的prototype属性
(3)constructor
console.log((20).constructor === Number) //true console.log((true).constructor === Boolean)//true console.log(('strr').constructor === String)//true console.log(([]).constructor === Array)//true console.log((function(){}).constructor === Function)//true console.log(({}).constructor === Object)//true
constructor有两个作用,一是判断数据类型,二是对象实例通过constroctor对象访问它的构造函数。需要注意的是,如果创建一个对象改变它的原型,就不能通过constructor来判断它的数据类型了。
function Fun(){ Fun.prototype = new Array(); var f = new Fun(); cosnole.log(f.constructor === Fun);// false copnsole.log(f.constructor === Array); //true
(4)Object.prototype.toString.call()
let a = Object.prototype.toString console.log(a.call(2)) //[object Number] console.log(a.call('str')) //[object String] console.log(a.call(true)) //[object Boolean] console.log(a.call(function(){})) //[object Function] console.log(a.call([])) //[object Array] console.log(a.call({})) //[object Object] console.log(a.call(undefined)) //[object Undefined] console.log(a.call(null)) //[object Null]
同样是检测对象obj调用toString方法,obj.toString()的结果和Object.prototype.toString.call(obj)的结果不一样,这是为什么?
是因为toString是Object的原型方法,而Array、function等类型作为Object的实例,都重写了toString方法。不同的对象类型调用toString方法时,根据原型链的知识,调用的是对应的重写之后的toString方法(function类型返回内容为函数体的字符串,Array类型返回元素组成的字符串),而不会去调用Object上原型toString方法(返回对象的具体类型),所以才有obj.toString()不能得到其对象类型,只能将obj转换为字符串类型;因此,在想要得到对象的具体类型时,应该调用Object原型上的toString方法
3、null和 undefined区别
1、首先Undefined和Null都是基本数据类型,这两个基本数据类型分别都只有一个值,就是Undefined和Null。
2、undefined代表的含义是未定义,null代表的含义是空对象。一般变量声明了但还没有定义的时候会返回undefined,null主要用于赋值给一些可能会返回对象的变量,作为初始化。
3、undefined在JavaScript中不是一个保留字,这意味着可以使用undefined来作为一个变量名,但是这样的做法是非常危险的,它会影响对undefined值的判断。我们可以通过一些方法获得安全的undefined值,比如说void 0.
4、当对这两种类型使用typeof进行判断时,Null类型化会返回“object”,这是一个历史遗留的问题。当使用双等号对两种类型的值进行判断会返回true,使用三等号时返回false。
4、instanceof操作符实现的原理及实现
instanceof运算符用于判断构造函数prototype属性是否出现在对象的原型链中的任何位置
myInstanceod(left,right){ // 获取对象的原型 let proto = Object.getPrototypeOf(left); // 获取构造函数的prototype对象 let prototype = right.prototype; // 判断构造函数的prototype对象是否在对象的原型链上 while(true){ if(!proto)return false; if(proto === prototype)return true; // 如果没有找到继续从原型上找,Object.getPrototypeOf方法用来获取指定对象的原型 proto = Object.getPrototypeOf(proto) } },
5、如何获取安全的undefined值?
因为undefined是一个标识符,所以可以被当作变量来使用和赋值,但是这样会影响undefined的正常判断。表达式void__没有返回值,因此返回结果是undefined。void并不改变表达式的结果,只是让表达式不返回。因此可以用void 0 来获得undefined.
6、Object.is()与比较操作符“===”、“==”的区别?
- 使用双等号(==)进行相等判断时,如果两边的类型不一致时,则会做强制类型转换后再比较。
- 使用三等号(===)进行相等判断时,如果两边类型不一致,不会做强制类型转换,直接返回false。
- 使用Object.is()来进行相等判断时,一般情况下和三等号的判断相同,它处理了一些特殊的情况,比如 -0 和 +0不在相等,两个NaN是相等的。
console.log(+0 === -0) //true console.log(Object.is(+0,-0)) //false console.log(NaN === NaN) //false console.log(Object.is(NaN,NaN)) //true console.log(undefined === undefined) //true console.log(Object.is(undefined,undefined)) //true console.log(null === null) //true console.log(Object.is(null,null)) //true
7、什么是JavaScript中的报装类型?
在JavaScript中,基本类型没有属性和方法,但是为了便于操作基本类型的值,在调用基本类型的属性和方法时,JavaScript会在后台隐式地将基本类型的值转换为对象,如:
const str = 'asd' str.length;//3 str.toUpperCase();//'ASD'
在访问‘asd’.length时,JavaScript将‘asd’在后台转换成String(‘asd’),然后再访问其length属性。
JavaScript也可以使用Object函数显式地将基本类型转换为报装类型:
var str = 'asd' Object(str)
也可以使用valueOf方法将报装类型倒转成基本类型:
var str = 'asd'; var b = Object(str); var c = b.valueOf();//'abc'
看看如下代码会打印出什么:
var a = new Boolean(false); if(!a){ console.log('asd');//never runs }
答案是什么斗殴不会打印,因为虽然包裹的基本类型是false,但是false被包裹成报装类型后就成了对象,所以其非值为false,所以环体中的内容不会运行。
8、为什么会有BigInt的提案?
JavaScript中,Number.MAX_SAFE_INTEGER 表示最大安全数,计算结果是9007199254740991,即在这个数范围内不会出现精度丢失(小数除外)。但是一旦超过这个范围,js就会出现计算不准确的情况,这在数计算的时候不得不依靠一些第三方库进行解决,因此官方提出了BigInt来解决此问题。
9、如何判断一个对象是空对象
- 使用JSON自带的.stringify方法来判断:
if(JSON.stringify(Obj) == '{}'){ console.log('空对象') }
- 使用ES6新增的方法Object.keys()来判断:
if(Object.keys(Obj).length
10、const对象的属性可以修改吗
const保证的并不是变量的值不能改动,而是变量指向的那个内存地址不能改动。对于基本类型的数据(数值,字符串,布尔值),其值保存在变量指向的那个内存地址,因此等同于常量。
但对于引用类型的数据(主要是对象和数组)来说,变量指向数据的内存地址,保存的只是一个指针,const只能保证这个指针是固定不变的,至于它指向的数据结构是不是可变的,就完全不能控制了。
const a = {}; //为a添加一个属性,可以成功 a.pro= 'asd' console.log(a.pro);//asd //将a 指向另一个对象,会报错 a = {};//TypeError:'a' is read-only
上面代码中,常量a储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把a指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。
const a = []; a.push('HELLO');//可执行 a.length = 0;//可执行 a = ['hello'];//报错 常量a 是一个数组,这个数组本省是可写的,但是如果将另一个数组赋值给a,就会报错。
11、如果new一个箭头函数会怎么样?
箭头函数是es6中提出来的,它没有prototype,也没有自己的this指向,更不可以使用arguments参数,所以不能new一个箭头函数
new操作符的实现步骤如下:
1、创建一个对象
2、将构造函数的作用域赋给新对象(也就是将对象的__proto__属性指向构造函数的prototype属性)
3、指向构造函数的代码。构造函数中的this指向该对象(也就是为这个对象添加属性和方法)
4、返回新的对象所以,上面的第二、第三步,箭头函数都是没办法执行的。
12、箭头函数的this指向哪里
箭头函数不同于传统JavaScript中的函数,箭头函数并没有属于自己的this,它所谓的this是捕获其所在上下文的this值,作为自己的this值,并且由于没有属于自己的this,所以是不会被new调用的,这个所谓的this也不会被改变。
可以用babel理解一下箭头函数
const obj = { getArrow(){ return ()=>{ console.log(this === obj) } } }
转化后:
//ES6,由babel转译 const obj1 = { getArrow:function getArrow(){ let _this = this return function(){ console.log(_this === obj1) } } }
13、扩展运算符的作用及使用场景
(1)对象扩展运算符
对象的扩展运算符(…)用于取出参数对象中的所有可遍历属性,拷贝到当前对象之中。
let bar = { a: 1, b: 2}; let baw = { ...bar };//{ a: 1, b: 2 }
上诉方法实际上等价于:
let bar = { a: 1, b: 2}; let baw = Object.assign({}, bar );// { a: 1, b: 2}
Object.assign方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。Object.assign方法的第一个参数是目标对象,后面的参数都是源对象。(如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性)
同样,如果自定义的属性,放在扩展运算符后面,则扩展运算符内部的同名属性会被覆盖掉
let bar = { a: 1, b: 2}; let baw = { ...bar, ...{a:2, b:4}};//{a: 2, b: 4}
利用上诉特性就可以很方便的修改对象的部分属性。在redux中的reducer函数规定必须是一个纯函数,reducer中的state对象要求不能直接修改,可以通过扩展运算符把修改路径的对象都复制一遍,然后产生一个新的对象返回。注意:扩展运算符对对象实例的拷贝属于浅拷贝
(2)数组扩展运算符
数组的扩展运算符可以将一个数组转为用逗号分隔的参数序列,且每次只能展开一层数组
console.log(...[1,2,3]) //1,2,3 console.log(...[1,[2,3,4],5]) //1,[2,3,4],5 console.log(...[1,...[2,3,4],5]) //1,2,3,4,5
数组扩展运算符的应用:
将数组转换为参数序列
function add(a, b){ return a + b; } const number = [1,2]; add(...number);
复制数组:
const arr1 = [1,2]; const arr2 = [...arr1];
扩展运算符(…)用于取出参数对象中的所有可遍历属性,拷贝到当前对象之中,这里参数对象是个数组,数组里面的所有对象都是基础数据类型,将所有基础数据类型重新拷贝到新的数组中
合并数组如果想在数组内合并数组,可以这样:
const str = ["two","three"]; const str2 = ["one",...str,"four","five"];// ['one', 'two', 'three', 'four', 'five']
扩展运算符与结构赋值结合起来,用于生成数组
const [first,...rest] = [1,2,3,4,5]; console.log("first:",first,"rest:",rest); //first:1 rest:2,3,4,5
注意:如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错:
const [...rest, first] = [1,2,3,4,5]; //报错 const [first, ...rest, last] = [1,2,3,4,5]; // 报错
将字符串转为真正的数组
console.log(...'hello') // ['h', 'e','l' ,' l ', 'o']
任何Iterator接口的对象,都可以用扩展运算符转为真正的数组比较常见的应用是可以将某些数据结构转为数组:
function foo(a,b){ let args = [...arguments] console.log('args',args) }, foor({a:1,b:2}) //[{a: 1, b: 2}] foor(1,2) //[1,2]
用于替换es5中的Array.prototype.slice.call(arguments)写法。
使用Math函数获取数组中特定的值
const number = [ 2,4,6,8,-12]; Math.min(...number);// -12 Math.max(...number);//8
14、proxy可以实现什么功能?
在vue3.0中通过Proxy来替换原本的Object.defineProperty来实现数据响应式。
Proxy是ES6中新增的功能,它可以用来自定义对象中的操作。
let p = new Proxy(target, handler)
代表需要添加代理的对象,handler用来自定义对象中的操作,比如可以用来自定义set或者get函数。
onWatch(obj,setBind,getLogger){ let handle = { get(target,property,receiver){ getLogger(target,property) return Reflect.get(target.property,receiver) }, set(target,property,value,receiver){ setBind(value,property) return Reflect.set(target,property,value) } } return new Proxy(obj,handle) }, useOnWatch(){ let obj = {a: 1} let p = onWatch( obj, (v,property) => { console.log(`监听到属性${property}改变为${v}`) }, (target,property) => { console.log(`${property} = ${target[property]}`) } ) console.log('-----',p) //对象proxy {a:1} p.a = 2 console.log('---',p.a,p) 监听到属性a改变为2 a = 2 },
上述代码中,通过自定义set和get函数的方式,在原本的逻辑中插入了我们的函数逻辑,实现了对对象任何属性进行读写时发出通知。
当然这是简单版的响应式实现,如果需要实现一个vue中的响应式,需要在get中收集依赖,在set派发更新,之所以Vue3.0要使用Proxy替换原本的API原因在于Proxy无需一层层递归为每个属性添加代理,一次即可完成以上操作,性能上更好,并且原本的实现由一些数据更新不能监听到,但是proxy可以完美监听到任何方式的数据改变,唯一缺陷就是浏览器的兼容性不好。
15、常用的正则表达式
// 匹配16进制颜色值 var regex1 = /#([0-9a-fA-F]{6}[0-9a-fA-F]{3})/g; // 匹配日期,如yyyy-mm-dd格式 var regex2 = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; // 手机号码正则 var regex3 = /^1[345678]\d{9}$/g; // 用户名正则 var regex4 = /^[a-zA-Z\$][a-zA-Z0-9_\$]{4,16}$/ // 匹配QQ号 var regex5 = /^[1-9][0-9]{4,10}$/g;
17、JavaScript脚本延迟加载的方式有哪些
延迟加载就是等页面加载完成之后再加载JavaScript文件。js延迟加载有助于提高页面加载速度。
一般有以下几种方式:
(1)defer属性
给js脚本添加defer属性,这个属性会让脚本的加载与文档的解析同步解析,然后在文档解析完成后再执行这个脚本文件,这样的话就能使页面的渲染不被阻塞。多个设置defer属性的脚步按规范来说最后是顺序执行的,但是一些浏览器中可能不是这样。
(2)async属性
给js脚本添加async属性,这个属性会使脚本异步加载,不会阻塞页面的解析过程,但是当脚本加载完成后立即执行js脚本,这个时候如果文档没有解析完成的话同样会阻塞。多个async属性的脚本的执行顺序是不可预测的,一般不会按照代码的顺序依次执行
(3)动态创建DOM方式
动态创建DOM标签的方式,可以对文档的加载事件进行监听,当文档加载完成后再动态的创建script标签来引入js脚本。
(4)使用setTimeout延迟方法
设置一个定时器来延迟加载js脚本文件让js最后加载:将js脚本放在文档的底部。来使js脚本尽可能的在最后来加载执行。
18、什么是DOM和BOM?
- DOM指的是文档对象模型,它指的是把文档当做一个对象,这个对象主要定义了处理网页内容的方法和接口。
- BOM指的是浏览器对象模型,它指的是把浏览器当做一个对象来对待,这个对象主要定义了与浏览器进行交互的方法和接口。BOM的核心是window,而window对象具有双重角色,它既是通过js访问浏览器窗口的一个接口,又是一个Global(全局)对象。这意味着在网页中定义的任何对象,变量和函数,都作为全局对象的一个属性或者方法存在。window对象含义location对象、navigator对象、screen对象等子对象,并且DOM的最根本的对象document对象也是BOM的window对象的子对象。
19、escape、encodeURI、encodeURIComponent的区别
1、encodeURI是对整个URI进行转义,将URI中的非法字符转换为合法字符,所以对于一些在URI中有特殊意义的字符不会进行转义。
2、encodeURIComponent是对URI的组成部分进行转义,所以一些特殊字符也会得到转义。
3、escape和encodeURI的作用相同,不过对于unicode编码为0xff之外字符的时候会有区别,escape是直接在字符的unicode编码前加上 %u ,而encodeURL首先会将字符转换为UTF-8的格式,再在每个字节前加上%。
21、什么是尾调用,使用尾调用有什么好处?
尾调用指的是函数的最后一步调用另一个函数。代码执行是基于执行栈的,所以当在一个函数里调用另一个函数时,会保留当前的执行上下文,然后再新建另一个执行上下文加入栈中。使用尾调用的话,因为已经是函数的最后一步,所以这时可以不必再保留当前的执行上下文,从而节省了内存,这就是尾调用的优化。但是es6的尾调用优化只在严格模式下开启,正常模式是无效的。
22、ES6模块与CommonJS模块有什么异同?
ES6 Module 和 Common JS 模块的区别:
Common JS 是对模块的浅拷贝,ES6 Module 是对模块的引用,即ES6 Module只读,不能改变其值,也就是指针指向不能改变,类似const;
import的接口是read-only(只读状态),不能修改其变量值。即不能修改其变量的指针指向,但可以改变变量内部指针指向,可以对commonJS重新赋值(改变指针指向),但是对ES6 Module复制会编译报错。
ES6 Module 和 Common JS模块的共同点:
CommonJS 和 ES6 Module都可以对引入的对象进行赋值,即对对象内部属性的值进行改变。
23、for…in 和 for…of的区别
for…in循环主要是为了遍历对象而生,不适用于遍历数组;for…of循环可以用来遍历数组、类数组对象,字符串、Set、Map以及Generator对象
for … of是ES6新增的遍历方式,允许遍历一个含有iterator接口的数据结构(数组、对象等)并且返回各项的值,和ES3中的for …in区别如下:
1、for…of遍历获取的是对象的链值,for…in获取的是对象的键名;
2、for…in会遍历对象的整个原型链,性能非常差不推荐使用,而for…of只遍历当前对象不会遍历原型链
3、对于数组的遍历,for…in会返回数组中所有可枚举的属性(包括原型链上可枚举的属性),for…of只返回数组的下标对应的属性值;
var obj = {a:1, b:2, c:3}; for (let key in obj) { console.log(key); } // a // b // c
const array1 = ['a', 'b', 'c']; for (const val of array1) { console.log(val); } // a // b // c
24、ajax、axios、fetch的区别
(1) AJAX
AJAX即“Asynchronous JavaScript And XML”(异步JavaScript和XML),是指一种创建交互式网页应用的网页开发技术。它是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。通过在后台与服务器进行少量数据交换,Ajax可以使网页实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。传统的网页(不使用Ajax)如果需要更新内容,必须重载整个网页页面。其缺点如下:本身是针对MVC编程,不符合前端MVVM的浪潮基于原生XHR开发,XHR本身的架构不清晰。不符合关注分离的原则配置和调用方式非常混乱,而且基于事件的异步模型不友好。
(2)Fetch
fetch号称是AJAX的替代品,是在ES6出现的,使用ES6中的promise对象。Fetch是基于promise设计的。Fetch的代码结构比起ajax简单多。fetch不是ajax的进一步封装,而是原生js,没有使用XMLHttpRequest对象。
- fetch的优点:语法简洁,更加语义化,基于标准promise实现,支持async/await更加底层,提供的API丰富(request,response)脱离了XHR,是ES规范里新的实现方式。
- fetch的缺点:fetch只对网络请求报错,对400,500都当做成功的请求,服务器返回400,500错误代码时并不会reject,只有网络错误这些导致请求不能完成时,fetch才会被reject。fetch默认不会带cookie,需要添加配置项:fetch(url,{credentials:‘include’})。fetch不支持abort,不支持超时控制,使用setTimeout及Promise.reject的实现的超时控制并不能阻止请求过程继续在后台运行,造成了流量的浪费,fetch没有办法原生监测请求的进度,而XHR可以
(3)Axios
Axios是一种基于Promise封装的HTTP客户端,其特点如下:
浏览器端发起XMLHttpRequest请求,node端发起http请求,支持Promise API 监听请求和返回,对请求和返回进行转化,自动转换json数据,客户端支持抵御XSRF攻击。
25、对原型、原型链的理解
(1)原型:在JavaScript中是使用构造函数来新建一个对象的,每一个构造函数的内部都有一个prototype属性,它的属性值是一个对象,这个对象包含了可以由该构造函数的所有实例共享的属性和方法。当使用构造函数新建一个对象后,在这个对象的内部将包含一个指针,这个指针指向构造函数的prototype属性对应的值,在ES5中这个指针指向构造函数的prototype属性对应的值,在ES5中这个指针被称为对象的原型。一般来说不应该能够获取到这个值的,但是现在浏览器中都实现了_proto_属性来访问这个属性,但是最好不要使用这个属性,因为它不是规范中规定的。ES5中新增了一个Object.getPrototypeOf()方法,可以通过这个方法来获取对象的原型。
(2)原型链:当访问一个对象的属性时,如果这个对象内部不存在这个属性,那么它就会去它的原型对象里找这个属性,这个原型对象又会有自己的原型,于是就这样一直找下去,也就是原型链的概念,原型链的尽头一般来说都是Object.prototype,所以这就是新建的对象为什么能够使用toString()等方法的原因。
特点:JavaScript对象是通过引用来传递的,创建的每个新对象实体中并没有一份属于自己的原型副本。当修改原型时,与之相关的对象也会继承这一改变
26、原型链的终点是什么?如何打印出原型链的终点?
由于Object是构造函数,原型链终点Object.prototype._proto_,而Object.prototype._proto_===null//true,所以,原型链的终点是null。原型链上的所有原型都是对象,所有的对象最终都是由Object构造的,而Object.prototype的下一级是Object.prototype._proto_。
27、对作用域、作用域链的理解
(1)全局作用域和函数作用域
1、全局作用域最外层函数和最外层函数外面定义的变量拥有全局作用域;所有未定义直接赋值的变量自动声明为全局作用域;所有window对象的属性拥有全局作用域;
全局作用域有很大的弊端,过多的全局作用域变量会污染全局命名空间,容易引起命名冲突。
2、函数作用域声明在函数内部的变量,一般只有固定的代码片段可以访问到,作用域是分层的,内层作用域可以访问外层作用域,反之不行
(2)块级作用域
块级作用域使用ES6中新增的let和const指令可以声明块级作用域,块级作用域可以在函数中创建也可以在一个代码块中创建(由{}包裹的代码片段)let和const声明的变量不会有变量提升,也不可以重复声明。在循环中比较适合绑定块级作用域,这样就可以把声明的计数器变量限制在循环内部。
(3)作用域链
- 在当前作用域中查找所需变量,但是该作用域没有这个变量,那这个变量就是自由变量。如果在自己作用域找不到该变量就去父级作用域查找,依次向上级作用域查找,直到访问到window对象就被终止,这一层层的关系就是作用域链。
- 作用域链的作用就是保证对执行环境有权访问的所有变量恶函数的有序访问,通过作用域链,可以访问到外层环境的变量和函数。
- 作用域链的本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。作用域链的前端始终都是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象。
- 当查找一个变量时,如果当前执行环境中没有找到,可以沿着作用域链向后查找。
28、对this对象的理解
this是执行上下文中的一个属性,它指向最后一次调用这个方法的对象。在实际开发中,this的指向可以通过四种调用模式来判断。
1、第一种是函数调用模式,当一个函数不是一个对象的属性时,直接作为函数来调用时,this指向全局对象。
2、第二种是方法调用模式,如果一个函数作为一个对象的方法来调用时,this指向这个对象。
3、第三种是构造器调用模式,如果一个函数用new调用时,函数执行前会新建一个对象,this指向这个新创建的对象。
4、第四种是apply、call和bind调用模式,这三个方法都可以显示的指定调用函数的this指向。其中apply方法接受两个参数:一个是this绑定的对象,一个是参数数组。call方法接收的参数,第一个是this绑定的对象,后面的其余参数是传入函数执行的参数。也就是说,在使用call()方法时,传递给函数的参数必须逐个列举出来(参数列表)。bind方法通过传入一个对象,返回一个this绑定了传入对象的新函数。这个函数的this指向除了使用new时会改变,其他情况下都不会改变。
这四种方式,使用构造器调用模式的优先级最高,接着是apply、call和bind调用模式,然后是方法调用模式,最后是函数调用模式。
29、call() 和 apply() 的区别
它们的作用一模一样,区别仅在于传入参数的形式的不同。
1、 apply接受两个参数,function.apply(thisArg, [argsArray]),第一个参数指定了函数体内this对象的指向,第二个参数为一个带下标的集合,这个集合可以为数组,也可以为类数组,apply方法把这个集合中的元素作为参数传递给被调用的函数。
2、call传入的参数数量不固定,跟apply相同的是,function.call(thisArg, arg1, arg2, arg…);第一个参数也是代表函数体内的this指向,从第二个参数开始往后,每个参数被依次传入函数。
#####30、异步编程的实现方式?
JavaScript中的异步机制可以分为以下几种:
1、回调函数的方式:使用回调函数的方式有一个缺点是,多个回调函数嵌套的时候会造成回掉地狱,上下两层的回调函数间的代码耦合度太高,不利于代码的可维护。
2、Promise的方式:使用promise的方式可以将嵌套的回调函数作为链式调用。但是使用这种方法,有时会造成多个then的链式调用,可能会造成代码的语义不够明确。
3、generator的方式:它可以在函数的执行过程中,将函数的执行权转移出去,在函数外部还可以将执行权转移回来。当遇到异步函数执行的时候,将函数执行权转移出去,当异步函数执行完毕时再将执行权给转移回来。因此在generator内部对于异步操作的方式,可以以同步的顺序来书写。使用这种方式需要考虑的问题是何时将函数的控制权转移回来,因此需要有一个自动执行generator的机制,比如说co模块等方式来实现generator的自动执行。
4、async函数的方式:async函数是generator和promise实现的一个自动执行的语法糖,它内部自带执行器,当函数内部执行到一个await语句的时候,如果语句返回一个promise对象,那么函数将会等待promise对象的状态变为resolve后再继续向下执行。因此可以将异步逻辑转化为同步的顺序来书写,并且这个函数可以自动执行。
31、对Promise的理解
Promise是异步编程的一种解决方案,它是一个对象,可以获取异步操作的消息,他的出现大大改善了异步编程的困境,避免了地狱回调,它比传统的解决方案回调函数和事件更合理和更强大。
所谓Promise,简单说就是一个容器,里面保存着某个未来才会执行的事件(通常是一个异步操作)的结果。从语法上说,Promise是一个对象,它可以获取异步操作的消息。Promise提供统一的API,各种异步操作都可以用同样的方法进行处理。
(1)Promise的实例有三个状态:
Pending(进行中)
Resolved(已完成)
Rejected(已拒绝)
当把一件事情交给promise,它的状态就是Pending,任务完成了状态就变成了Resolved、没有完成失败了Rejected。
(2)Promise的实例有两个过程:
pending ——>fulfilled:Resolved(已完成)
pending ——>rejected:Rejected(已拒绝)
注意:一旦从进行状态变成其他状态就永远不能更改状态了。
(3)Promise的特点:
对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态,pending(进行中)、fulfilled(已成功)、rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态,这也是promise这个名字的由来——“承诺”;一旦状态改变就不会再改变,任何时候都可以得到这个结果,promise对象的状态改变,只有两种可能:从pending变为fulfilled,从pending变为rejected。这时就称为resolved(已定型)。如果改变已经发生了,你再对promise对象添加回调函数,也会立即得到这个结果。这与事件(event)完全不同,事件的特点是:如果你错过了它,再去监听是得不到结果的。
(4)Promise的缺点:
无法取消Promise,一旦新建它就会立即执行,无法中途取消。如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。当处于pending状态时,无法得知目前进展到哪一个阶段(刚开始还是即将完成)
总结:
Promise对象是异步编程的一种解决方案,最早由社区提出。Promise是一个构造函数,接收一个函数作为参数,返回一个Promise实例。一个Promise实例有三种状态,分别是pending、resolved和rejected,分别代表了进行中、已成功和已失败。实例的状态只能由pending转变resolved或者rejected状态,并且状态一经改变,就凝固了,无法再改变了。
状态的改变是通过resolved()和reject()函数来实现的,可以在异步操作结束后调用这两个函数改变Promise实例的状态,它的原型上定义了一个then方法,使用这个then方法可以为两个状态的改变注册回调函数。这个回调函数属于微任务,会在本轮事件循环的末尾执行。
注意:在构造Promise的时候,构造函数内部的代码是立即执行的
32、Promisej解决了什么问题
在工作中经常会碰到这样一个需求,比如我使用ajax发一个A请求后,成功拿到数据,需要把数据传给B请求;那么需要如下代码:
let file = require('fs') file.readFile('./file.text','utf8',function(err,data){ file.readFile(data,'utf8',function(err,data){ file.readFile(data,'utf8',function(err,data){ conosle.log(data) }) }) })
上面代码有如下缺点:
后一个请求需要依赖于前一个请求成功后,将数据往下传递,会导致多个ajax请求嵌套的情况,代码不够直观。
如果前后两个请求不需要传递参数的情况下,后一个请求也需要前一个请求成功后再执行下一步操作,这种情况下,那么也需要如上编写代码。
Promise出现后,代码变成这样:
let file = require('fs') function read(url){ return new Promise((resolve,reject)=>{ file.readFile(url,'utf8',function(err,data){ err && reject(err) resolve(data); }) }) } read('./file.text').then(data=>{ return read(data) }).then(data=>{ return read(data) }).then(data=>{ console.log(data) })
这样代码看起来就简洁了很多,解决了回调地狱的问题
33、对async/await的理解
async/await其实是generator的语法糖,它能实现的效果都能用then链来实现,它是为优化then链而开发出来的。从字面上来看,async是“异步”的简写,await则为等待,所以很好理解async用于申明一个function是异步的,而await只能出现在async函数中。
先看看async函数返回了什么
所以,async函数返回的是一个Promise对象。async函数(包含函数语句、函数表达式、Lambda表达式)会返回一个Promise对象,如果在函数中reurn一个直接量,async会把这个直接量通过Promise.resolve()封装成Promise对象。
asyn函数返回的是一个Promise对象,所以在最外层不能用await获取其返回值的情况下,当然应该用原来的方式:then()链来处理这个Promise对象,就像这样:
async function testAsync(){ return 'hello world' } let result = testAsync(); console.log(result) result.then(v=>{ console.log(v) //hello world })
那如果async函数没有返回值,又该如何?很容易想到,它会返回Promise.resolve(undefined)。
联想一下Promise的特点——无等待,所有在没有await的情况下执行async函数,他会立即执行,返回一个Promise对象,并且,绝不会阻塞后面的语句。这个普通返回promise对象的函数并无二致。
注意:Promise.resolve(x)可以看作是new Promise(resolve => resolve(x))的简写。可以用于快速封装字面量对象或其他对象,将其封装成Promise实例。
34、async/await的优势
单一的Promise链并不能发现async/await的优势,但是,如果需要处理由多个Promise组成的then链的时候,优势就能体现出来了(Promise通过then链来解决多层回调的问题,现在又用async/await来进一步优化它)。
假设一个业务,分多个步骤完成,每个步骤都是异步的,而且依赖于上一个步骤的结果。仍然用setTimeout来模拟异步操作:
/** * 传入参数n,表示这个函数执行的时间(毫秒) * 执行的结果是n+200,这个值将用于下一步骤 */ function takeLongTime(n){ return new Promise(resolve => { setTimeout(()=> resolve(n+200),n); }) } function step1(n){ console.log(`step1 width ${n}`) return takeLongTime(n); } function step2(n){ console.log(`step2 width ${n}`) return takeLongTime(n); } function step3(n){ console.log(`step3 width ${n}`) return takeLongTime(n); }
现在用Promise方式来实现这三个步骤的处理:
function doIt(){ console.time("doIt") const time1 = 300; step1(time1) .then(time2 => step2(time2)) .then(time3 => step3(time3)) .then(result => { console.log(`result is ${result}`) console.timeEnd("doIt") }) }, doIt(); // step1 width 300 //step2 width 500 //step3 width 700 //result is 900 //doIt: 1512.216064453125 ms
输出结果result是 step3()的参数 700 + 200 = 900。doIt()顺序执行了三个步骤,一共用了300+500+700 = 1500毫秒,和console.time()/console.timeEnd()计算的结果一致。
如果用async/await来实现呢,会是这样:
async function doIt(){ console.log("doIt") const time1 = 300; const time2 = await step1(time1); const time3 = await step2(time2) const result = await step3(time3) console.log(`result is ${result}`) console.timeEnd("doIt") } doIt()
结果和之前的Promise实现是一样的,但是这个代码看起来是不是清晰得多,几乎跟同步代码一样。
35、async/await对比Promise的优势
1、代码读起来更加同步,Promise虽然摆脱了回调地狱,但是then的链式调用也会带来额外的阅读负担。
2、Promise传递中间值非常麻烦,而async/await可以用成熟的try/catch,Promise的错误捕获非常冗余。
3、调试友好,Promise的调试很差,由于没有代码块,你不能在一个返回表达式的箭头函数中设置断点,如果你在一个.then代码块中使用调试器的步进(step-over)功能,调试器并不会进入后续的.then代码块,因为调试器只能跟踪同步代码的每一步。、
36、对象创建的方式有哪些?
(1)第一种是字面量的形式
一般使用字面量的形式直接创建对象,但是这种创建方式对于创建大量相似对象的时候,会产生大量的重复代码。但js和一般的面向对象的语言不同,在ES6之前它没有类的概念。但是可以使用函数来进行模拟,从而产生出可复用的对象创建方式,常见的有以下几种:(1)第一种是工厂模式,工厂模式的主要原理是用函数来封装创建对象的细节,从而通过调用函数来达到复用的目的。但是它有一个很大的问题就是创建出来的对象无法和某个类型联系起来,它只是简单的封装了复用代码,而没有建立起对象和类型间的关系。
// 字面量创建对象 var obj = { name: "张三", age: 20, sty() { console.log(this.name + "爱睡觉"); } } obj.sty()
(2)第二种是构造函数模式
js中每一个函数都可以作为构造函数,只要一个函数是通过new来调用的,那么就可以称它为构造函数。执行构造函数首先会创建一个对象,然后将对象的原型指向构造函数的prototype属性,然后将执行上下文中的this指向这个对象,最后再执行整个函数,如果返回值不是对象,则返回新建的对象。因为this的值指向了新建的对象,因此可以使用this给对象赋值。
构造函数模式相对于工厂模式的优点是,所创建的对象和构造函数存在一个缺点就是,造成了不必要的函数对象的创建,因为在js中函数也是一个对象,因此如果对象属性中如果包含函数的话,那么每次都会新建一个函数对象,浪费了不必要的内存空间,因为函数是所有的实例都可以通用的。
// 内置构造函数创建对象 var obj1 = new Object() obj1.name = "小白" obj.fn = function () { console.log("内置构造函数创建对象"); } console.log(obj1.name); obj1.fn()
(3)原型模式
每一个函数都有一个prototype属性,这个属性是一个对象,它包含了通过构造函数创建的所有实例都能共享的属性和方法。因此可以使用原型对象来添加公用属性和方法,从而实现代码的复用。这种方式相对于构造函数模式来说,解决了函数对象的复用问题。但是这种模式也存在一些问题,一个是没有办法通过传入参数来初始化值,另一个是如果存在一个引用类型如Array这样的值,那么所有的实例将共享一个对象,一个实例对引用类型值的改变会影响所有的实例。
function Person(){ Person.prototype.name = 'shess' Person.prototype.age = 20 Person.prototype.job = 'engineer' Person.prototype.sayName = function(){ console.log(this.name) } } var person1 = new Person();
(4)组合使用构造函数模式和原型模式
这是创建自定义的最常见方式。因为构造函数模式和原型模式分开使用都存在一些问题,因此可以组合使用这两种模式,通过构造函数来初始化对象的属性,通过原型对象来实现函数方法的复用。这种方法很好解决了两种模式单独使用时的缺点,但是有一点不足的就是,因为使用了两种不同的模式,所以对于代码的封装性不够好。
function Person(name,age,job){ this.name = name; this.age = age; this.job = job this.friends = ['ss','aa'] } Person.prototype = { constructor:Person, sayName: function(){ console.log(this.name) } } var person1 = new Person('sss',20,'engineer)
(5)动态原型模式
这是一种模式将原型方法赋值的创建过程移动到了构造函数的内部,通过对属性是否存在的判断,可以实现仅在第一次调用函数时对原型对象赋值一次的效果。这一种方式很好地对上面的混合模式进行了封装。
function Person(name,age,job){ this.name = name; this.age = age this.job = job if(typeof this.sayName != 'function'){ Person.prototype.sayName = function(){ console.log(this.name) } } } var person1 = new Person('sss',20,'engineer');
(6)寄生构造函数模式
这一种模式和工厂模式的实现基本相同,我对这个模式的理解是,它主要是基于一个已有的类型,在实例化时对实例化的对象进行扩展。这样既不用修改原来的构造函数,也达到了扩展对象的目的。它的一个缺点和工厂模式一样,无法实现对象的识别。
function Person(name,age,job){ var o = new Object(); o.name = name; o.age = age o.job = job o.sayName = function(){ console.log(this.name) } return o } var person1 = new Person('sss',20,'engineer')
37、对象继承的方式有哪些?
(1)以原型链的方式来实现继承
这种实现方式存在的缺点是,在包含有引用类型的数据时,会被所有的实例对象所共享,容易造成修改的混乱。还有就是在创建子类型的时候不能向超类型传递参数。
原型链继承涉及的构造函数、原型和实例,三者之间存在着一定的关系,即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针。
function Parent1() { this.name = 'parent1'; this.play = [1, 2, 3] } function Child1() { this.type = 'child2'; } Child1.prototype = new Parent1(); console.log(new Child1());
上面的代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题,我再举个例子来说明这个问题。
let s1 = new Child1(); let s2 = new Child2(); s1.play.push(4); console.log(s1.play, s2.play);
打印结果
明明我只改变了 s1 的 play 属性,为什么 s2 也跟着变了呢?原因很简单,因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。
(2)使用借用构造函数(借助call)
这种方式是通过在子类型的函数中调用超类型的构造函数来实现的,这一种方式解决了不能向超类型传参的缺点,但是它存在的一个问题就是无法实现函数方法的复用,并且超类型原型定义的方法子类型也没有办法访问到。
function Parent1(){ this.name = 'parent1'; } Parent1.prototype.getName = function () { return this.name; } function Child1(){ Parent1.call(this); this.type = 'child1' } let child = new Child1(); console.log(child); // 没问题 console.log(child.getName()); // 会报错
最后打印的 child 在控制台显示,除了 Child1 的属性 type 之外,也继承了 Parent1 的属性 name。这样写的时候子类虽然能够拿到父类的属性值,解决了第一种继承方式的弊端,但问题是,父类原型对象中一旦存在父类之前自己定义的方法,那么子类将无法继承这些方法。这种情况的控制台执行结果会报child.getName is not a function
因此,从上面的结果就可以看到构造函数实现继承的优缺点,它使父类的引用属性不会被共享,优化了第一种继承方式的弊端;但是随之而来的缺点也比较明显——只能继承父类的实例属性和方法,不能继承原型属性或者方法。
(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 执行了两次,第一次是改变Child3 的 prototype 的时候,第二次是通过 call 方法调用 Parent3 的时候,那么 Parent3 多构造一次就多进行了一次性能开销,这是我们不愿看到的。
(4)原型式继承
原型式继承的主要思路就是基于已有的对象来创建新的对象,实现的原理是,向函数中传入一个对象,然后返回一个以这个对象为原型的对象。这种继承的思路主要不是为了实现创造一种新的类型,只是对某个对象实现一种简单继承,ES5中定义的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); console.log(person4.name === person4.getName()); console.log(person5.name); console.log(person4.friends); console.log(person5.friends);
第一个结果“tom”,比较容易理解,person4 继承了 parent4 的 name 属性,但是在这个基础上又进行了自定义。
第二个是继承过来的 getName 方法检查自己的 name 是否和属性里面的值一样,答案是 true。
第三个结果“parent4”也比较容易理解,person5 继承了 parent4 的 name 属性,没有进行覆盖,因此输出父对象的属性。
最后两个输出结果是一样的,讲到这里你应该可以联想到 02 讲中浅拷贝的知识点,关于引用数据类型“共享”的问题,其实 Object.create 方法是可以为一些对象实现浅拷贝的。
那么关于这种继承方式的缺点也很明显,多个实例的引用类型属性指向相同的内存,存在篡改的可能
(5)寄生式继承
寄生式继承的思路是创建一个用于封装继承过程的函数,通过传入一个对象,然后复制一个对象的副本,然后对对象进行扩展,最后返回这个对象。(使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法)这个扩展的过程就可以理解是一种继承。这种继承的优点就是对一个简单对象实现继承,如果这个对象不是自定义类型。缺点是没办法实现函数复用。
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); console.log(person6.getName()); console.log(person6.getFriends());
(6)寄生式组合继承
组合继承的缺点就是使用超类型的实例作为子类型的原型,导致添加了不必要的原型属性。寄生式组合继承的方式是使用超类型的原型的副本来作为子类型的原型,这样就避免了创建不必要的属性。
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); console.log(person6.getName()); console.log(person6.getFriends());
(7)ES6 的 extends 关键字实现继承
extends实现的底层逻辑
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() // 成功访问到父类的方法
38、哪些情况会导致内存泄漏
(1)意外的全局变量:由于使用未声明的变量,而意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收。
(2)被遗忘的计时器或回调函数:设置了setInterval定时器,而忘记取消它,如果循环函数有对外部变量的引用的话,那么这个变量会被一直留在内存中,而无法被回收。
(3)脱离DOM的引用:获取一个DOM元素的引用,而后面这个元素被删除,由于一直保留了对这个元素的引用,所以它也无法被回收。
(4)闭包:不合理的使用闭包,从而导致某些变量一直被留在内存中。
- 使用ES6新增的方法Object.keys()来判断:
- 使用JSON自带的.stringify方法来判断: