转载于https://github.com/poetries/FE-Interview-Questions,by poetries
JS
中分为七种内置类型,七种内置类型又分为两大类型:基本类型和对象(Object
)。null
,undefined
,boolea
n,number
,string
,symbol
。JS
的数字类型是浮点类型的,没有整型。并且浮点类型基于 IEEE 754
标准实现,在使用中会遇到某些 Bug。NaN
也属于 number
类型,并且 NaN
不等于自身。let a = 111 // 这只是字面量,不是 number 类型
a.toString() // 使用时候才会转换为对象类型
对象(
Object
)是引用类型,在使用过程中会遇到浅拷贝和深拷贝的问题。
let a = { name: ‘FE‘ }
let b = a
b.name = ‘EF‘
console.log(a.name) // EF
typeof
对于基本类型,除了null
都可以显示正确的类型
typeof 1 // ‘number‘
typeof ‘1‘ // ‘string‘
typeof undefined // ‘undefined‘
typeof true // ‘boolean‘
typeof Symbol() // ‘symbol‘
typeof b // b 没有声明,但是还会显示 undefined
typeof
对于对象,除了函数都会显示object
typeof [] // ‘object‘
typeof {} // ‘object‘
typeof console.log // ‘function‘
对于
null
来说,虽然它是基本类型,但是会显示object
,这是一个存在很久了的Bug
typeof null // ‘object‘
PS:为什么会出现这种情况呢?因为在
JS
的最初版本中,使用的是32
位系统,为了性能考虑使用低位存储了变量的类型信息,000
开头代表是对象,然而null
表示为全零,所以将它错误的判断为object
。虽然现在的内部类型判断代码已经改变了,但是对于这个Bug
却是一直流传下来。
Object.prototype.toString.call(xx)
。这样我们就可以获得类似 [object Type]
的字符串let a
// 我们也可以这样判断 undefined
a === undefined
// 但是 undefined 不是保留字,能够在低版本浏览器被赋值
let undefined = 1
// 这样判断就会出错
// 所以可以用下面的方式来判断,并且代码量更少
// 因为 void 后面随便跟上一个组成表达式
// 返回就是 undefined
a === void 0
转Boolean
在条件判断时,除了
undefined
,null
,false
,NaN
,‘‘
,0
,-0
,其他所有值都转为true
,包括所有对象
对象转基本类型
对象在转换基本类型时,首先会调用
valueOf
然后调用toString
。并且这两个方法你是可以重写的
let a = {
valueOf() {
return 0
}
}
四则运算符
只有当加法运算时,其中一方是字符串类型,就会把另一个也转为字符串类型。其他运算只要其中一方是数字,那么另一方就转为数字。并且加法运算会触发三种类型转换:将值转换为原始值,转换为数字,转换为字符串
1 + ‘1‘ // ‘11‘
2 * ‘2‘ // 4
[1, 2] + [2, 1] // ‘1,22,1‘
// [1, 2].toString() -> ‘1,2‘
// [2, 1].toString() -> ‘2,1‘
// ‘1,2‘ + ‘2,1‘ = ‘1,22,1‘
对于加号需要注意这个表达式
‘a‘ + + ‘b‘
‘a‘ + + ‘b‘ // -> "aNaN"
// 因为 + ‘b‘ -> NaN
// 你也许在一些代码中看到过 + ‘1‘ -> 1
== 操作符
这里来解析一道题目
[] == ![] // -> true
,下面是这个表达式为何为true
的步骤
// [] 转成 true,然后取反变成 false
[] == false
// 根据第 8 条得出
[] == ToNumber(false)
[] == 0
// 根据第 10 条得出
ToPrimitive([]) == 0
// [].toString() -> ‘‘
‘‘ == 0
// 根据第 6 条得出
0 == 0 // -> true
比较运算符
toPrimitive
转换对象unicode
字符索引来比较prototype
属性,除了 Function.prototype.bind()
,该属性指向原型。__proto__
属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[prototype]]
,但是 [[prototype]]
是内部属性,我们并不能访问到,所以使用 _proto_
来访问。__proto__
来寻找不属于该对象的属性,__proto__
将对象连接起来组成了原型链this
在调用 new 的过程中会发生以上四件事情,我们也可以试着来自己实现一个 new
function create() {
// 创建一个空的对象
let obj = new Object()
// 获得构造函数
let Con = [].shift.call(arguments)
// 链接到原型
obj.__proto__ = Con.prototype
// 绑定 this,执行构造函数
let result = Con.apply(obj, arguments)
// 确保 new 出来的是个对象
return typeof result === ‘object‘ ? result : obj
}
instanceof
可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的prototype
我们也可以试着实现一下
instanceof
function instanceof(left, right) {
// 获得类型的原型
let prototype = right.prototype
// 获得对象的原型
left = left.__proto__
// 判断对象的类型是否等于类型的原型
while (true) {
if (left === null)
return false
if (prototype === left)
return true
left = left.__proto__
}
}
function foo() {
console.log(this.a)
}
var a = 1
foo()
var obj = {
a: 2,
foo: foo
}
obj.foo()
// 以上两者情况 `this` 只依赖于调用函数前的对象,优先级是第二个情况大于第一个情况
// 以下情况是优先级最高的,`this` 只会绑定在 `c` 上,不会被任何方式修改 `this` 指向
var c = new foo()
c.a = 3
console.log(c.a)
// 还有种就是利用 call,apply,bind 改变 this,这个优先级仅次于 new
看看箭头函数中的
this
function a() {
return () => {
return () => {
console.log(this)
}
}
}
console.log(a()()())
箭头函数其实是没有
this
的,这个函数中的this
只取决于他外面的第一个不是箭头函数的函数的this
。在这个例子中,因为调用a
符合前面代码中的第一个情况,所以this
是window
。并且 this 一旦绑定了上下文,就不会被任何代码改变
当执行 JS 代码时,会产生三种执行上下文
eval
执行上下文每个执行上下文中都有三个重要的属性
VO
),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问JS
采用词法作用域,也就是说变量的作用域是在定义时就决定了)this
var a = 10
function foo(i) {
var b = 20
}
foo()
对于上述代码,执行栈中有两个上下文:全局上下文和函数 foo 上下文。
stack = [
globalContext,
fooContext
]
对于全局上下文来说,
VO
大概是这样的
globalContext.VO === globe
globalContext.VO = {
a: undefined,
foo: <Function>,
}
对于函数
foo
来说,VO
不能访问,只能访问到活动对象(AO
)
fooContext.VO === foo.AO
fooContext.AO {
i: undefined,
b: undefined,
arguments: <>
}
// arguments 是函数独有的对象(箭头函数没有)
// 该对象是一个伪数组,有 `length` 属性且可以通过下标访问元素
// 该对象中的 `callee` 属性代表函数本身
// `caller` 属性代表函数的调用者
对于作用域链,可以把它理解成包含自身变量对象和上级变量对象的列表,通过
[[Scope]]
属性查找上级变量
fooContext.[[Scope]] = [
globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
fooContext.VO,
globalContext.VO
]
接下来让我们看一个老生常谈的例子,
var
b() // call b
console.log(a) // undefined
var a = ‘Hello world‘
function b() {
console.log(‘call b‘)
}
想必以上的输出大家肯定都已经明白了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行上下文时,会有两个阶段。第一个阶段是创建的阶段(具体步骤是创建
VO
),JS
解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为undefined
,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用。
b() // call b second
function b() {
console.log(‘call b fist‘)
}
function b() {
console.log(‘call b second‘)
}
var b = ‘Hello world‘
var
会产生很多错误,所以在ES6
中引入了let
。let
不能在声明前使用,但是这并不是常说的let
不会提升,let
提升了声明但没有赋值,因为临时死区导致了并不能在声明前使用。
var foo = 1
(function foo() {
foo = 10
console.log(foo)
}()) // -> ? foo() { foo = 10 ; console.log(foo) }
因为当
JS
解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到foo
,但是这个值又是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。
specialObject = {};
Scope = specialObject + Scope;
foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}
delete Scope[0]; // remove specialObject from the front of scope chain
闭包的定义很简单:函数 A 返回了一个函数 B,并且函数 B 中使用了函数 A 的变量,函数 B 就被称为闭包。
function A() {
let a = 1
function B() {
console.log(a)
}
return B
}
你是否会疑惑,为什么函数
A
已经弹出调用栈了,为什么函数B
还能引用到函数A
中的变量。因为函数A
中的变量这时候是存储在堆上的。现在的JS
引擎可以通过逃逸分析辨别出哪些变量需要存储在堆上,哪些需要存储在栈上。
经典面试题,循环中使用闭包解决 var 定义函数的问题
for ( var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
setTimeout
是个异步函数,所有会先把循环全部执行完毕,这时候 i
就是 6
了,所以会输出一堆 6
。for (var i = 1; i <= 5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j * 1000);
})(i);
}
setTimeout
的第三个参数for ( var i=1; i<=5; i++) {
setTimeout( function timer(j) {
console.log( j );
}, i*1000, i);
}
第三种就是使用
let
定义i
了
for ( let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
因为对于
let
来说,他会创建一个块级作用域,相当于
{ // 形成块级作用域
let i = 0
{
let ii = i
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
i++
{
let ii = i
}
i++
{
let ii = i
}
...
}
letet a a = {
age : 1
}
let b = a
a.age = 2
console.log(b.age) // 2
浅拷贝
首先可以通过
Object.assign
来解决这个问题
let a = {
age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1
当然我们也可以通过展开运算符
(…)
来解决
let a = {
age: 1
}
let b = {...a}
a.age = 2
console.log(b.age) // 1
通常浅拷贝就能解决大部分问题了,但是当我们遇到如下情况就需要使用到深拷贝了
let a = {
age: 1,
jobs: {
first: ‘FE‘
}
}
let b = {...a}
a.jobs.first = ‘native‘
console.log(b.jobs.first) // native
浅拷贝只解决了第一层的问题,如果接下去的值中还有对象的话,那么就又回到刚开始的话题了,两者享有相同的引用。要解决这个问题,我们需要引入深拷
深拷贝
这个问题通常可以通过
JSON.parse(JSON.stringify(object))
来解决
let a = {
age: 1,
jobs: {
first: ‘FE‘
}
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = ‘native‘
console.log(b.jobs.first) // FE
但是该方法也是有局限性的:
undefined
let obj = {
a: 1,
b: {
c: 2,
d: 3,
},
}
obj.c = obj.b
obj.e = obj.a
obj.b.c = obj.c
obj.b.d = obj.b
obj.b.e = obj.b.c
let newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)
如果你有这么一个循环引用对象,你会发现你不能通过该方法深拷贝
undefined
的时候,该对象也不能正常的序列化let a = {
age: undefined,
jobs: function() {},
name: ‘poetries‘
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "poetries"}
lodash
的深拷贝函数。在有
Babel
的情况下,我们可以直接使用ES6
的模块化
// file a.js
export function a() {}
export function b() {}
// file b.js
export default function() {}
import {a, b} from ‘./a.js‘
import XXX from ‘./b.js‘
CommonJS
CommonJs
是Node
独有的规范,浏览器中使用就需要用到Browserify
解析了。
// a.js
module.exports = {
a: 1
}
// or
exports.a = 1
// b.js
var module = require(‘./a.js‘)
module.a // -> log 1
在上述代码中,
module.exports
和exports
很容易混淆,让我们来看看大致内部实现
var module = require(‘./a.js‘)
module.a
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,
// 重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
a: 1
}
// 基本实现
var module = {
exports: {} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
// 导出的东西
var a = 1
module.exports = a
return module.exports
};
再来说说
module.exports
和exports
,用法其实是相似的,但是不能对exports
直接赋值,不会有任何效果。
对于
CommonJS
和ES6
中的模块化的两者区别是:
require(${path}/xx.js)
,后者目前不支持,但是已有提案,前者是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。require/exports
来执行的AMD
AMD
是由RequireJS
提出的
// AMD
define([‘./a‘, ‘./b‘], function(a, b) {
a.do()
b.do()
})
define(function(require, exports, module) {
var a = require(‘./a‘)
a.doSomething()
var b = require(‘./b‘)
b.doSomething()
})
你是否在日常开发中遇到一个问题,在滚动事件中需要做个复杂计算或者实现一个按钮的防二次点击操作。
wait
,防抖的情况下只会调用一次,而节流的 情况会每隔一定时间(参数wait
)调用函数// 这个是用来获取当前时间戳的
function now() {
return +new Date()
}
/**
* 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行
*
* @param {function} func 回调函数
* @param {number} wait 表示时间窗口的间隔
* @param {boolean} immediate 设置为ture时,是否立即调用函数
* @return {function} 返回客户调用函数
*/
function debounce (func, wait = 50, immediate = true) {
let timer, context, args
// 延迟执行函数
const later = () => setTimeout(() => {
// 延迟函数执行完毕,清空缓存的定时器序号
timer = null
// 延迟执行的情况下,函数会在延迟函数中执行
// 使用到之前缓存的参数和上下文
if (!immediate) {
func.apply(context, args)
context = args = null
}
}, wait)
// 这里返回的函数是每次实际调用的函数
return function(...params) {
// 如果没有创建延迟执行函数(later),就创建一个
if (!timer) {
timer = later()
// 如果是立即执行,调用函数
// 否则缓存参数和调用上下文
if (immediate) {
func.apply(this, params)
} else {
context = this
args = params
}
// 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个
// 这样做延迟函数会重新计时
} else {
clearTimeout(timer)
timer = later()
}
}
}
null
,就可以再次点击了。ID
,如果是延迟调用就调用函数防抖动和节流本质是不一样的。防抖动是将多次执行变为最后一次执行,节流是将多次执行变成每隔一段时间执行
/**
* underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait
*
* @param {function} func 回调函数
* @param {number} wait 表示时间窗口的间隔
* @param {object} options 如果想忽略开始函数的的调用,传入{leading: false}。
* 如果想忽略结尾函数的调用,传入{trailing: false}
* 两者不能共存,否则函数不能执行
* @return {function} 返回客户调用函数
*/
_.throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
// 之前的时间戳
var previous = 0;
// 如果 options 没传则设为空对象
if (!options) options = {};
// 定时器回调函数
var later = function() {
// 如果设置了 leading,就将 previous 设为 0
// 用于下面函数的第一个 if 判断
previous = options.leading === false ? 0 : _.now();
// 置空一是为了防止内存泄漏,二是为了下面的定时器判断
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function() {
// 获得当前时间戳
var now = _.now();
// 首次进入前者肯定为 true
// 如果需要第一次不执行函数
// 就将上次时间戳设为当前的
// 这样在接下来计算 remaining 的值时会大于0
if (!previous && options.leading === false) previous = now;
// 计算剩余时间
var remaining = wait - (now - previous);
context = this;
args = arguments;
// 如果当前调用已经大于上次调用时间 + wait
// 或者用户手动调了时间
// 如果设置了 trailing,只会进入这个条件
// 如果没有设置 leading,那么第一次会进入这个条件
// 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了
// 其实还是会进入的,因为定时器的延时
// 并不是准确的时间,很可能你设置了2秒
// 但是他需要2.2秒才触发,这时候就会进入这个条件
if (remaining <= 0 || remaining > wait) {
// 如果存在定时器就清理掉否则会调用二次回调
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
// 判断是否设置了定时器和 trailing
// 没有的话就开启一个定时器
// 并且不能不能同时设置 leading 和 trailing
timeout = setTimeout(later, remaining);
}
return result;
};
};
在 ES5 中,我们可以使用如下方式解决继承的问题
function Super() {}
Super.prototype.getNumber = function() {
return 1
}
function Sub() {}
let s = new Sub()
Sub.prototype = Object.create(Super.prototype, {
constructor: {
value: Sub,
enumerable: false,
writable: true,
configurable: true
}
})
ES6
中,我们可以通过 class
语法轻松解决这个问题class MyDate extends Date {
test() {
return this.getTime()
}
}
let myDate = new MyDate()
myDate.test()
ES6
不是所有浏览器都兼容,所以我们需要使用 Babel
来编译这段代码。myDate.test()
你会惊奇地发现出现了报错因为在
JS
底层有限制,如果不是由Date
构造出来的实例的话,是不能调用Date
里的函数的。所以这也侧面的说明了:ES6
中的class
继承与ES5
中的一般继承写法是不同的。
Date
构造出来,那么我们可以改变下思路实现继承function MyData() {
}
MyData.prototype.test = function () {
return this.getTime()
}
let d = new Date()
Object.setPrototypeOf(d, MyData.prototype)
Object.setPrototypeOf(MyData.prototype, Date.prototype)
_proto__
转而连接到子类的 prototype
=> 子类的 prototype
的 __proto__
改为父类的 prototype
。JS
底层的这个限制call
和 apply
都是为了解决改变 this
的指向。作用都是相同的,只是传参的方式不同。call
可以接收一个参数列表,apply
只接受一个参数数组let a = {
value: 1
}
function getValue(name, age) {
console.log(name)
console.log(age)
console.log(this.value)
}
getValue.call(a, ‘yck‘, ‘24‘)
getValue.apply(a, [‘yck‘, ‘24‘])
Promise
看成一个状态机。初始是 pending
状态,可以通过函数 resolve
和 reject
,将状态转变为 resolved
或者 rejected
状态,状态一旦改变就不能再次变化。then
函数会返回一个 Promise
实例,并且该返回值是一个新的实例而不是之前的实例。因为 Promise
规范规定除了 pending
状态,其他状态是不可以改变的,如果返回的是一个相同实例的话,多个 then
调用就失去意义了。then
来说,本质上可以把它看成是 flatMap
// 三种状态
const PENDING = "pending";
const RESOLVED = "resolved";
const REJECTED = "rejected";
// promise 接收一个函数参数,该函数会立即执行
function MyPromise(fn) {
let _this = this;
_this.currentState = PENDING;
_this.value = undefined;
// 用于保存 then 中的回调,只有当 promise
// 状态为 pending 时才会缓存,并且每个实例至多缓存一个
_this.resolvedCallbacks = [];
_this.rejectedCallbacks = [];
_this.resolve = function (value) {
if (value instanceof MyPromise) {
// 如果 value 是个 Promise,递归执行
return value.then(_this.resolve, _this.reject)
}
setTimeout(() => { // 异步执行,保证执行顺序
if (_this.currentState === PENDING) {
_this.currentState = RESOLVED;
_this.value = value;
_this.resolvedCallbacks.forEach(cb => cb());
}
})
};
_this.reject = function (reason) {
setTimeout(() => { // 异步执行,保证执行顺序
if (_this.currentState === PENDING) {
_this.currentState = REJECTED;
_this.value = reason;
_this.rejectedCallbacks.forEach(cb => cb());
}
})
}
// 用于解决以下问题
// new Promise(() => throw Error(‘error))
try {
fn(_this.resolve, _this.reject);
} catch (e) {
_this.reject(e);
}
}
MyPromise.prototype.then = function (onResolved, onRejected) {
var self = this;
// 规范 2.2.7,then 必须返回一个新的 promise
var promise2;
// 规范 2.2.onResolved 和 onRejected 都为可选参数
// 如果类型不是函数需要忽略,同时也实现了透传
// Promise.resolve(4).then().then((value) => console.log(value))
onResolved = typeof onResolved === ‘function‘ ? onResolved : v => v;
onRejected = typeof onRejected === ‘function‘ ? onRejected : r => throw r;
if (self.currentState === RESOLVED) {
return (promise2 = new MyPromise(function (resolve, reject) {
// 规范 2.2.4,保证 onFulfilled,onRjected 异步执行
// 所以用了 setTimeout 包裹下
setTimeout(function () {
try {
var x = onResolved(self.value);
resolutionProcedure(promise2, x, resolve, reject)