前端面试题记录

前端面试题记录。✒️

写 React / Vue 项目时为什么要在列表组件中写 key,其作用是什么

Key是React用于追踪那些列表中元素被修改、被添加或者被移除的辅助标识。

在开发过程中,我们需要保证某个元素的key 在其同级元素中具有唯-一性。 在React Diff算法中React 会借助元素的Key值来判断该元素是新近创建的还是被移动而来的元素,从而减少不必要的元素重渲染。此外,React还需要借助Key值来判断元素与本地状态的关联关系,因此我们绝不可忽视转换函数中Key的重要性。

['1', '2', '3'].map(parseInt) what & why

parseInt

parseInt(string, radix) 将一个字符串 string 转换为 radix 进制的整数, radix 为介于2-36之间的数。

string:要被解析的值。如果参数不是一个字符串,则将其转换为字符串(使用 ToString抽象操作)。字符串开头的空白符将会被忽略。

radix:一个介于2和36之间的整数(数学系统的基础),表示上述字符串的基数。比如参数 10 表示使用十进制数值系统。

返回值:返回解析后的整数值。 如果被解析参数的第一个字符无法被转化成数值类型,则返回 NaN。

注意:
radix为 undefined,或者radix为 0 或者没有指定的情况下,JavaScript 作如下处理:

  • 如果字符串 string 以”0x”或者”0X”开头, 则基数是16 (16进制).
  • 如果字符串 string 以”0”开头, 基数是8(八进制)或者10(十进制),那么具体是哪个基数由实现环境决定。ECMAScript 5 规定使用10,但是并不是所有的浏览器都遵循这个规定。因此,永远都要明确给出radix参数的值。
  • 如果字符串 string 以其它任何值开头,则基数是10 (十进制)。

所以这题的实际代码为

1
2
3
['1', '2', '3'].map((item, index) => {
return parseInt(item, index)
})

即返回值是:

1
2
3
parseInt('1', 0) // 1
parseInt('2', 1) // NaN
parseInt('3', 2) // NaN, 3不是二进制

所以这道题的答案是:['1', '2', '3'].map(parseInt) // 1, NaN, NaN

什么是防抖和节流?有什么区别?如何实现

  1. 防抖

触发高频事件后n秒内函数只会执行一次,如果n秒内高频事件再次被触发,则重新计算时间

  • 思路:

每次触发事件时都取消之前的延时调用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function debounce(fn) {
let timeout = null; // 创建一个标记用来存放定时器的返回值
return function () {
clearTimeout(timeout); // 每当用户输入的时候把前一个 setTimeout clear 掉
timeout = setTimeout(() => { // 然后又创建一个新的 setTimeout, 这样就能保证输入字符后的 interval 间隔内如果还有字符输入的话,就不会执行 fn 函数
fn.apply(this, arguments);
}, 500);
};
}
function sayHi() {
console.log('防抖成功');
}

var inp = document.getElementById('inp');
inp.addEventListener('input', debounce(sayHi)); // 防抖
  1. 节流

高频事件触发,但在n秒内只会执行一次,所以节流会稀释函数的执行频率

  • 思路:

每次触发事件时都判断当前是否有等待执行的延时函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function throttle(fn) {
let canRun = true; // 通过闭包保存一个标记
return function () {
if (!canRun) return; // 在函数开头判断标记是否为true,不为true则return
canRun = false; // 立即设置为false
setTimeout(() => { // 将外部传入的函数的执行放在setTimeout中
fn.apply(this, arguments);
// 最后在setTimeout执行完毕后再把标记设置为true(关键)表示可以执行下一次循环了。当定时器没有执行的时候标记永远是false,在开头被return掉
canRun = true;
}, 500);
};
}
function sayHi(e) {
console.log(e.target.innerWidth, e.target.innerHeight);
}
window.addEventListener('resize', throttle(sayHi));

介绍下 Set、Map、WeakSet 和 WeakMap 的区别

详情🔎

ES5/ES6 的继承除了写法以外还有什么区别

  1. ES5 和 ES6 子类 this 生成顺序不同。ES5 的继承先生成了子类实例,再调用父类的构造函数修饰子类实例,ES6 的继承先生成父类实例,再调用子类的构造函数修饰父类实例。这个差别使得 ES6 可以继承内置对象。
  2. ES6子类可以直接通过 proto 寻址到父类。而通过 ES5 的方式,Sub.proto === Function.prototype
1
2
3
4
5
6
class Super {}
class Sub extends Super {}

const sub = new Sub();

Sub.__proto__ === Super;
1
2
3
4
5
6
7
8
9
function Super() {}
function Sub() {}

Sub.prototype = new Super();
Sub.prototype.constructor = Sub;

var sub = new Sub();

Sub.__proto__ === Function.prototype;
  1. 区别于ES5的继承,ES6的继承实现在于使用super关键字调用父类,反观ES5是通过call或者apply回调方法调用父类。

setTimeout、Promise、Async/Await 的区别

主要是来考察三者在事件循环中的区别。事件循环中分为宏观任务队列微观任务队列

JS是单线程的,对于异步操作只能先将其放在一边,按照某种规则按先后顺序放在某个容器中(其实就是宏观任务队列和微观任务队列)。先处理同步任务,再处理异步任务。异步任务分为宏观任务队列和微观任务队列。

宏观任务

  • script(整体代码)
  • setTimeout
  • setInterval
  • I/O
  • UI交互事件
  • postMessage
  • MessageChannel
  • setImmediate(Node.js 环境)

微观任务

  • Promise.then
  • MutaionObserver
  • process.nextTick(Node.js 环境)
  • async/await实际上是promise+generator的语法糖,也就是promise,也就是微观任务

执行顺序是同步任务结束后,先处理微观任务后处理宏观任务

setTimeout

1
2
3
4
5
6
console.log('script start')	//1. 打印 script start
setTimeout(() => {
console.log('setTimeout') // 4. 打印 settimeout
}, 0) // 2. 调用 setTimeout 函数,并定义其完成后执行的回调函数
console.log('script end') //3. 打印 script start
// 输出顺序:script start->script end->setTimeout

注:就算设置的是0秒后执行,输出顺序也不会变

Promise

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('script start') // 1.同步任务
let promise1 = new Promise(function (resolve) {
console.log('promise1') // 2. 同步任务
resolve(console.log('resolve end')) // 3.同步任务
console.log('promise1 end') // 4. 同步任务
}).then(function () {
console.log('promise2') // 6. 微观任务
})
setTimeout(function(){
console.log('setTimeout') // 7. 宏观任务
})
console.log('script end') // 5. 同步任务
// 输出顺序: script start -> promise1 -> resolve end -> promise1 end -> script end -> promise2 -> setTimeout

Promise本身是同步的立即执行函数,而Promise.then()才是微观任务

Async/Await

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function async1(){
console.log('async1 start');
await async2();
console.log('async1 end')
}
async function async2(){
console.log('async2 start')
await async3();
console.log('async2 end')
}

let async3 = () => {
console.log('async3')
}

console.log('script start');
async1();
console.log('script end')

// 输出顺序 script start -> async1 start -> async2 start -> async3 -> script end -> async2 end -> async1 end

async返回一个Promise函数 ,遇到await会立即执行await后面的代码。而await下面的代码会放在微观任务队列中。

await的含义为等待,也就是 async 函数需要等待await后的函数执行完成并且有了返回结果(Promise对象)之后,才能继续执行下面的代码。await通过返回一个Promise对象来实现同步的效果。

Async/Await 如何通过同步的方式实现异步

占位

异步笔试题

请写出下面代码的运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');

运行结果:script start -> async1 start -> async2 -> promise1 -> script end -> async1 end -> promise2 -> setTimeout

先大体看下代码:

从上往下看,先走同步队列,再走异步队列(包含微观任务队列和宏观任务队列)。

同步队列:script start → async1 start → async2 → promise1 → script end

异步队列:包括微观任务和宏观任务。

微观任务:async1 end → promise2

宏观任务: setTimeout →

算法手写题

已知如下数组:

var arr = [ [1, 2, 2], [3, 4, 5, 5], [6, 7, 8, 9, [11, 12, [12, 13, [14] ] ] ], 10];

编写一个程序将数组扁平化去并除其中重复部分数据,最终得到一个升序且不重复的数组

1
Array.from(new Set(arr.flat(Infinity))).sort((a, b) => a-b)

arr.flat(Infinity)用于将嵌套的数组拉平

new Set(arr)用于去重,返回一个类数组的Set数据结构

Array.from(set)将一个类数组的数据结构转换为真正的数组

sort用于数组的大小排序

array

JS 异步解决方案的发展历程以及优缺点

回调函数(callback)

1
2
3
setTimeout(() => {
// callback 函数体
}, 1000)

缺点:回调地狱,不能用 try catch 捕获错误,不能 return

回调地狱的根本问题在于:

  • 缺乏顺序性: 回调地狱导致的调试困难,和大脑的思维方式不符
  • 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身,即(控制反转
  • 嵌套函数过多的多话,很难处理错误
1
2
3
4
5
6
7
8
9
ajax('XXX1', () => {
// callback 函数体
ajax('XXX2', () => {
// callback 函数体
ajax('XXX3', () => {
// callback 函数体
})
})
})

优点:解决了同步的问题(只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。)

Promise

Promise就是为了解决callback的问题而产生的。

Promise 实现了链式调用,也就是说每次 then 后返回的都是一个全新 Promise,如果我们在 then 中 return ,return 的结果会被 Promise.resolve() 包装

优点:解决了回调地狱的问题

1
2
3
4
5
6
7
8
9
10
ajax('XXX1')
.then(res => {
// 操作逻辑
return ajax('XXX2')
}).then(res => {
// 操作逻辑
return ajax('XXX3')
}).then(res => {
// 操作逻辑
})

缺点:无法取消 Promise ,错误需要通过回调函数来捕获

Generator

特点:可以控制函数的执行,可以配合 co 函数库使用

1
2
3
4
5
6
7
8
9
function *fetch() {
yield ajax('XXX1', () => {})
yield ajax('XXX2', () => {})
yield ajax('XXX3', () => {})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()

Async/await

async、await 是异步的终极解决方案

优点是:代码清晰,不用像 Promise 写一大堆 then 链,处理了回调地狱的问题

缺点:await 将异步代码改造成同步代码,如果多个异步操作没有依赖性而使用 await 会导致性能上的降低。

1
2
3
4
5
6
7
async function test() {
// 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
// 如果有依赖性的话,其实就是解决回调地狱的例子了
await fetch('XXX1')
await fetch('XXX2')
await fetch('XXX3')
}

下面来看一个使用 await 的例子:

1
2
3
4
5
6
7
8
let a = 0
let b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
}
b()
a++
console.log('1', a) // -> '1' 1

对于以上代码你可能会有疑惑,让我来解释下原因

  • 首先函数 b 先执行,在执行到 await 10 之前变量 a 还是 0,因为 await 内部实现了 generatorgenerator 会保留堆栈中东西,所以这时候 a = 0 被保存了下来
  • 因为 await 是异步操作,后来的表达式不返回 Promise 的话,就会包装成 Promise.reslove(返回值),然后会去执行函数外的同步代码
  • 同步代码执行完毕后开始执行异步代码,将保存下来的值拿出来使用,这时候 a = 0 + 10

上述解释中提到了 await 内部实现了 generator,其实 await 就是 generator 加上 Promise的语法糖,且内部实现了自动执行 generator。如果你熟悉 co 的话,其实自己就可以实现这样的语法糖。

Promise 构造函数是同步执行还是异步执行,那么 then 方法呢?

由下图可知,Promise构造函数是同步的,then方法是异步的

  • Promise.then是微观任务
  • setTimeout是宏观任务
  • 执行顺序:同步 -> 微观 -> 宏观

promise

如何实现一个 new

new运算符的工作原理

  1. 一个新对象被创建,它继承自构造函数的prototypefoo.prototype
  2. 构造函数被执行,执行的时候,相应的参数会被传入,同时上下文(this)会被指定为这个新的实例。new foonew foo()相同,只能用在不传递任何参数的情况。
  3. 如果构造函数返回了一个对象,那么这个对象会取代返回出来的结果。如果构造函数没有返回出来对象,那么new出来的结果为步骤1创建的结果。
1
2
3
4
5
function _new(fn, ...arg) {
const obj = Object.create(fn.prototype);
const ret = fn.apply(obj, arg);
return ret instanceof Object ? ret : obj;
}

React 中 setState 什么时候是同步的,什么时候是异步的?

在React中,如果是由React引发的事件处理(比如通过onClick引发的事件处理),调用setState不会同步更新this.state,除此之外的setState调用会同步执行this.state。所谓“除此之外”,指的是绕过React通过addEventListener直接添加的事件处理函数,还有通过setTimeout/setInterval产生的异步调用。

原因:在React的setState函数实现中,会根据一个变量isBatchingUpdates判断是直接更新this.state还是放到队列中回头再说,而isBatchingUpdates默认是false,也就表示setState会同步更新this.state,但是,有一个函数batchedUpdates,这个函数会把isBatchingUpdates修改为true,而当React在调用事件处理函数之前就会调用这个batchedUpdates,造成的后果,就是由React控制的事件处理过程setState不会同步更新this.state

这里所说的同步异步, 并不是真正的同步异步, 它还是同步执行的。

这里的异步指的是多个state会合成到一起进行批量更新。

React setState 笔试题,下面的代码输出什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Example extends React.Component {
constructor() {
super();
this.state = {
val: 0
};
}

componentDidMount() {
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 1 次 log

this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 2 次 log

setTimeout(() => {
this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 3 次 log

this.setState({val: this.state.val + 1});
console.log(this.state.val); // 第 4 次 log
}, 0);
}

render() {
return null;
}
};

答案为:0,0,2,3

  1. 第一次和第二次都是在 react 自身生命周期内,触发时 isBatchingUpdatestrue,所以并不会直接执行更新 state,而是加入了 dirtyComponents,所以打印时获取的都是更新前的状态 0。

  2. 两次 setState 时,获取到 this.state.val 都是 0,所以执行时都是将 0 设置成 1,在 react 内部会被合并掉,只执行一次。设置完成后 state.val 值为 1。

  3. setTimeout 中的代码,触发时isBatchingUpdates 为 false,所以能够直接进行更新,所以连着输出 2,3。

isBatchingUpdates 默认值为 false,当 react 自身的事件处理函数或 react 生命周期触发时,isBatchingUpdates 会被赋值为 true,当更新完成时又会被复原为 false。

有以下 3 个判断数组的方法,请分别介绍它们之间的区别和优劣

Object.prototype.toString.call() 、 instanceof 以及 Array.isArray()

1. Object.prototype.toString.call()

每一个继承 Object 的对象都有 toString 方法,如果 toString 方法没有重写的话,会返回 [Object type],其中 type 为对象的类型。但当除了 Object 类型的对象外,其他类型直接使用 toString 方法时,会直接返回都是内容的字符串,所以我们需要使用call或者apply方法来改变toString方法的执行上下文。

1
2
3
const an = ['Hello','An'];
an.toString(); // "Hello,An"
Object.prototype.toString.call(an); // "[object Array]"

这种方法对于所有基本的数据类型都能进行判断,即使是 null 和 undefined 。

1
2
3
4
5
6
7
Object.prototype.toString.call('An') // "[object String]"
Object.prototype.toString.call(1) // "[object Number]"
Object.prototype.toString.call(Symbol(1)) // "[object Symbol]"
Object.prototype.toString.call(null) // "[object Null]"
Object.prototype.toString.call(undefined) // "[object Undefined]"
Object.prototype.toString.call(function(){}) // "[object Function]"
Object.prototype.toString.call({name: 'An'}) // "[object Object]"

Object.prototype.toString.call() 常用于判断浏览器内置对象时。

2. instanceof

instanceof 的内部机制是通过判断对象的原型链中是不是能找到类型的 prototype

使用 instanceof判断一个对象是否为数组,instanceof 会判断这个对象的原型链上是否会找到对应的 Array 的原型,找到返回 true,否则返回 false

1
[]  instanceof Array; // true

instanceof 只能用来判断对象类型,原始类型不可以。并且所有对象类型 instanceof Object 都是 true。

1
[]  instanceof Object; // true

3. Array.isArray()

  • 功能:用来判断对象是否为数组

  • instanceof 与 isArray

    当检测Array实例时,Array.isArray 优于 instanceof ,因为 Array.isArray 可以检测出 iframes

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    var iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    xArray = window.frames[window.frames.length-1].Array;
    var arr = new xArray(1,2,3); // [1,2,3]

    // Correctly checking for Array
    Array.isArray(arr); // true
    Object.prototype.toString.call(arr); // true
    // Considered harmful, because doesn't work though iframes
    arr instanceof Array; // false
  • Array.isArray()Object.prototype.toString.call()

    Array.isArray()是ES5新增的方法,当不存在 Array.isArray() ,可以用 Object.prototype.toString.call() 实现。

    1
    2
    3
    4
    5
    if (!Array.isArray) {
    Array.isArray = function(arg) {
    return Object.prototype.toString.call(arg) === '[object Array]';
    };
    }

介绍下观察者模式和订阅-发布模式的区别,各自适用于什么场景

观察者模式中主体和观察者是互相感知的,发布-订阅模式是借助第三方来实现调度的,发布者和订阅者之间互不感知

  1. 发布-订阅模式就好像报社, 邮局和个人的关系,报纸的订阅和分发是由邮局来完成的。报社只负责将报纸发送给邮局。
  2. 观察者模式就好像 个体奶农和个人的关系。奶农负责统计有多少人订了产品,所以个人都会有一个相同拿牛奶的方法。奶农有新奶了就负责调用这个方法。

全局作用域中,用 const 和 let 声明的变量不在 window 上,那到底在哪里?如何去获取?

在ES5中,顶层对象的属性和全局变量是等价的,var 命令和 function 命令声明的全局变量,自然也是顶层对象。

1
2
3
4
5
var a = 12;
function f(){};

console.log(window.a); // 12
console.log(window.f); // f(){}

但ES6规定,var 命令和 function 命令声明的全局变量,依旧是顶层对象的属性,但 let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。

1
2
3
4
5
let aa = 1;
const bb = 2;

console.log(window.aa); // undefined
console.log(window.bb); // undefined

在全局作用域中,用 let 和 const 声明的全局变量并没有在全局对象中,只是一个块级作用域(Script)中

怎么获取?在定义变量的块级作用域中就能获取啊,既然不属于顶层对象,那就不加 window(global)呗。

1
2
3
4
5
let aa = 1;
const bb = 2;

console.log(aa); // 1
console.log(bb); // 2

改造下面的代码,使之输出0 - 9,写出你能想到的所有解法。

1
2
3
4
5
for (var i = 0; i< 10; i++){
setTimeout(() => {
console.log(i);
}, 1000)
}

方法一

1
2
3
4
5
for (let i = 0; i< 10; i++){
setTimeout(() => {
console.log(i);
}, 1000)
}

原理:利用 let 变量的特性 — 在每一次 for 循环的过程中,let 声明的变量会在当前的块级作用域里面(for 循环的 body 体,也即两个花括号之间的内容区域)创建一个文法环境(Lexical Environment),该环境里面包括了当前 for 循环过程中的 i

方法二

1
2
3
4
5
for (var i = 0; i < 10; i++) {
setTimeout(i => {
console.log(i);
}, 1000, i)
}

原理:利用 setTimeout 函数的第三个参数,会作为回调函数的第一个参数传入

Virtual DOM 真的比操作原生 DOM 快吗?谈谈你的想法。

尤雨溪的回答

1. 原生 DOM 操作 vs. 通过框架封装操作。

这是一个性能 vs. 可维护性的取舍。框架的意义在于为你掩盖底层的 DOM 操作,让你用更声明式的方式来描述你的目的,从而让你的代码更容易维护。没有任何框架可以比纯手动的优化 DOM 操作更快,因为框架的 DOM 操作层需要应对任何上层 API 可能产生的操作,它的实现必须是普适的。针对任何一个 benchmark,我都可以写出比任何框架更快的手动优化,但是那有什么意义呢?在构建一个实际应用的时候,你难道为每一个地方都去做手动优化吗?出于可维护性的考虑,这显然不可能。框架给你的保证是,你在不需要手动优化的情况下,我依然可以给你提供过得去的性能。

2. 对 React 的 Virtual DOM 的误解。

React 从来没有说过 “React 比原生操作 DOM 快”。React 的基本思维模式是每次有变动就整个重新渲染整个应用。如果没有 Virtual DOM,简单来想就是直接重置 innerHTML。很多人都没有意识到,在一个大型列表所有数据都变了的情况下,重置 innerHTML 其实是一个还算合理的操作… 真正的问题是在 “全部重新渲染” 的思维模式下,即使只有一行数据变了,它也需要重置整个 innerHTML,这时候显然就有大量的浪费。

我们可以比较一下 innerHTML vs. Virtual DOM 的重绘性能消耗:

  • innerHTML: render html string O(template size) + 重新创建所有 DOM 元素 O(DOM size)
  • Virtual DOM: render Virtual DOM + diff O(template size) + 必要的 DOM 更新 O(DOM change)

Virtual DOM render + diff 显然比渲染 html 字符串要慢,但是!它依然是纯 js 层面的计算,比起后面的 DOM 操作来说,依然便宜了太多。可以看到,innerHTML 的总计算量不管是 js 计算还是 DOM 操作都是和整个界面的大小相关,但 Virtual DOM 的计算量里面,只有 js 计算和界面大小相关,DOM 操作是和数据的变动量相关的。前面说了,和 DOM 操作比起来,js 计算是极其便宜的。这才是为什么要有 Virtual DOM:它保证了 1)不管你的数据变化多少,每次重绘的性能都可以接受;2) 你依然可以用类似 innerHTML 的思路去写你的应用。

3. MVVM vs. Virtual DOM

相比起 React,其他 MVVM 系框架比如 Angular, Knockout 以及 Vue、Avalon 采用的都是数据绑定:通过 Directive/Binding 对象,观察数据变化并保留对实际 DOM 元素的引用,当有数据变化时进行对应的操作。MVVM 的变化检查是数据层面的,而 React 的检查是 DOM 结构层面的。MVVM 的性能也根据变动检测的实现原理有所不同:Angular 的脏检查使得任何变动都有固定的
O(watcher count) 的代价;Knockout/Vue/Avalon 都采用了依赖收集,在 js 和 DOM 层面都是 O(change):

  • 脏检查:scope digest O(watcher count) + 必要 DOM 更新 O(DOM change)
  • 依赖收集:重新收集依赖 O(data change) + 必要 DOM 更新 O(DOM change)可以看到,Angular 最不效率的地方在于任何小变动都有的和 watcher 数量相关的性能代价。但是!当所有数据都变了的时候,Angular 其实并不吃亏。依赖收集在初始化和数据变化的时候都需要重新收集依赖,这个代价在小量更新的时候几乎可以忽略,但在数据量庞大的时候也会产生一定的消耗。

MVVM 渲染列表的时候,由于每一行都有自己的数据作用域,所以通常都是每一行有一个对应的 ViewModel 实例,或者是一个稍微轻量一些的利用原型继承的 “scope” 对象,但也有一定的代价。所以,MVVM 列表渲染的初始化几乎一定比 React 慢,因为创建 ViewModel / scope 实例比起 Virtual DOM 来说要昂贵很多。这里所有 MVVM 实现的一个共同问题就是在列表渲染的数据源变动时,尤其是当数据是全新的对象时,如何有效地复用已经创建的 ViewModel 实例和 DOM 元素。假如没有任何复用方面的优化,由于数据是 “全新” 的,MVVM 实际上需要销毁之前的所有实例,重新创建所有实例,最后再进行一次渲染!这就是为什么题目里链接的 angular/knockout 实现都相对比较慢。相比之下,React 的变动检查由于是 DOM 结构层面的,即使是全新的数据,只要最后渲染结果没变,那么就不需要做无用功。

Angular 和 Vue 都提供了列表重绘的优化机制,也就是 “提示” 框架如何有效地复用实例和 DOM 元素。比如数据库里的同一个对象,在两次前端 API 调用里面会成为不同的对象,但是它们依然有一样的 uid。这时候你就可以提示 track by uid 来让 Angular 知道,这两个对象其实是同一份数据。那么原来这份数据对应的实例和 DOM 元素都可以复用,只需要更新变动了的部分。或者,你也可以直接 track by $index 来进行 “原地复用”:直接根据在数组里的位置进行复用。在题目给出的例子里,如果 angular 实现加上 track by $index 的话,后续重绘是不会比 React 慢多少的。甚至在 dbmonster 测试中,Angular 和 Vue 用了 track by $index 以后都比 React 快: dbmon (注意 Angular 默认版本无优化,优化过的在下面)

顺道说一句,React 渲染列表的时候也需要提供 key 这个特殊 prop,本质上和 track-by 是一回事。

4. 性能比较也要看场合

在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求。Virtual DOM 为了提升小量数据更新时的性能,也需要针对性的优化,比如 shouldComponentUpdate 或是 immutable data。

  • 初始渲染:Virtual DOM > 脏检查 >= 依赖收集
  • 小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化) > Virtual DOM 无优化
  • 大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化

不要天真地以为 Virtual DOM 就是快,diff 不是免费的,batching 么 MVVM 也能做,而且最终 patch 的时候还不是要用原生 API。在我看来 Virtual DOM 真正的价值从来都不是性能,而是它 1) 为函数式的 UI 编程方式打开了大门;2) 可以渲染到 DOM 以外的 backend,比如 ReactNative。

5. 总结

以上这些比较,更多的是对于框架开发研究者提供一些参考。主流的框架 + 合理的优化,足以应对绝大部分应用的性能需求。如果是对性能有极致需求的特殊情况,其实应该牺牲一些可维护性采取手动优化:比如 Atom 编辑器在文件渲染的实现上放弃了 React 而采用了自己实现的 tile-based rendering;又比如在移动端需要 DOM-pooling 的虚拟滚动,不需要考虑顺序变化,可以绕过框架的内置实现自己搞一个。

下面的代码打印什么内容,为什么?

1
2
3
4
5
var b = 10;
(function b(){
b = 20;
console.log(b);
})();

返回一个函数

1
2
3
4
ƒ b(){
b = 20;
console.log(b);
}

作用域:执行上下文中包含作用域链:
在理解作用域链之前,先介绍一下作用域,作用域可以理解为执行上下文中申明的变量和作用的范围;包括块级作用域/函数作用域;
特性:声明提前:一个声明在函数体内都是可见的,函数声明优先于变量声明;
在非匿名自执行函数中,函数变量为只读状态无法修改;

简单改造下面的代码,使之分别打印 10 和 20。

1
2
3
4
5
var b = 10;
(function b(){
b = 20;
console.log(b);
})();

打印10

1
2
3
4
5
var b = 10;
(function b(){
var b = 20;
console.log(b);
})();

打印20

1
2
3
4
5
var b = 10;
(function b(){
var b = 20;
console.log(this.b);
})();

实现一个 sleep 函数

比如 sleep(1000) 意味着等待1000毫秒,可从 Promise、Generator、Async/Await 等角度实现

Promise实现

1
2
3
4
5
6
7
const sleep = (time) => {
return new Promise(resolve => setTimeout(resolve, time))
}

sleep(1000).then(() => {
console.log('1秒中后执行')
})

Async/Await实现

1
2
3
4
5
6
7
8
9
10
const sleep = (time) => {
return new Promise(resolve => setTimeout(resolve, time))
}

const sleepAsync = async () => {
await sleep(1000);
console.log('1秒中后执行')
}

sleepAsync();

使用 sort() 对数组 [3, 15, 8, 29, 102, 22] 进行排序,输出结果

结果为:

1
[102, 15, 22, 29, 3, 8]

arr.sort([compareFunction])

sort() 方法用原地算法对数组的元素进行排序,并返回数组。默认排序顺序是在将元素转换为字符串,然后比较它们的UTF-16代码单元值序列时构建的。

如需要按照大小进行排序则需要添加函数

arr.sort(a - b)

Author: YangLeLe
Link: http://younglele.cn/front-end-interview/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.