攻破javascript面试的完美指南(开发者视角)

2018-10-20

0. 前言

本文适合有一定js基础的前端开发人员阅读。原文是我google时无意发现的, 被一些知识点清晰的解析所打动, 决定翻译并记录下来。这个过程断续进行了两个月, 期间工作遇到的部分疑问也在文中找到了答案。这篇好的文章值得被推荐。

说明:因为外网的缘故, 原文中的一些视频连接并没有贴出。部分采用意译, 示例代码有少许差别。由于英文水平有限, 欢迎指出错误和批评。

为了向你说明js面试的复杂性, 尝试给出代码段的输出。

  1. console.log(2.0 == '2' == new Boolean(true) == '1')
  2. // true

十有八九的会给出false, 其实运行结果是true。

JavaScript是难的。 如果太聪明面试问类似问题, 我们也无可奈何。 但是什么是我们应该准备的呢?深入学习这十一个基本知识点,有助于你的JS面试。

1.熟悉js函数

function 是JavaScript的精髓。不同于其他语言, 在js中, 一个函数可以分配成一个变量, 作为参数传递给其他函数也可以作为其他函数的返回值。

  1. console.log(square1(5));
  2. /* ... */
  3. function square1(n) { return n * n; }
  4. // 25
  5. console.log(square2(5));
  6. var square2 = function(n) {
  7. return n * n;
  8. }
  9. // square2 is not a function

JS中, 如果你把函数定义为变量, 变量的名字会被提升, 但是JS执行到它的定义才能被访问。

你可能在一些代码中频繁的见到如下代码。

  1. var simpleLibrary = function() {
  2. var simpleLibrary = {
  3. a: 0,
  4. b: 0,
  5. add: function(a, b) {
  6. return a + b;
  7. },
  8. subtract: function(a, b) {
  9. return a - b;
  10. }
  11. }
  12. return simpleLibrary;
  13. }();

一个函数变量中变量和函数被分装, 可以避免全局变量污染。 从JQuery到Lodash的库采用这用技术提供$、_等

2.熟悉bind、apply和call

你可能在所有常用库中看到过这三个函数。它们允许局部套用, 我们可以把功能组合到不同的函数。一个优秀的js开发者可以随时告诉你关于这三个函数。

首先, 这些都是函数的原型方法去改变行为来实现一些功能。依据JS开发者Chad, 用途描述如下:

当你想要函数在特定上下文中调用,使用.bind(), 很适用于事件。 当你期望立即调用函数并修改上下文, 使用.call()或.apply()

一个应急调用实例

解释一下上述描述。假设你的数学老师要求你创建一个库并提交。你写了一个可以计算圆周长和面积的抽象库。

  1. var mathLib = {
  2. pi: 3.14,
  3. area: function(r) {
  4. return this.pi * r * r;
  5. },
  6. circumference: function(r) {
  7. return 2 * this.pi * r;
  8. },
  9. }

你把函数库提交给老师。现在是时间提交被称为计算库的代码。

  1. mathLib.area(2) // 12.56

当你提交第二个代码实例时, 你发现指南中老师要求你常量pi精确到小数点后5位数。你使用的是3.14, 不是3.14159。现在由于最后期限已过你没有机会提交库。 JS call函数可以帮你。 只需要调用你的代码如下。

  1. mathLib.area.call({pi: 3.14159}, 2) // 12.56636

加入你注意到call函数具有两个参数。

  • 上下文
  • 函数参数

在area函数中, 上下文是对象被关键词this代替。后面的参数作为函数参数被传递。 如下:

  1. var cylinder = {
  2. pi: 3.14,
  3. volume: function(r, h) {
  4. return this.pi * r * r * h;
  5. },
  6. }

call 调用如下:

  1. cylinder.volume.call({pi: 3.14159}, 2, 6); // 75.39815999999999

你看到这些函数的参数在上下文对象后被传递了吗?

Apply 是相似的, 除了函数参数以列表的方式被传递。

  1. cylinder.volume.apply({ pi: 3.14159 }, [2, 6]); // 75.39815999999999

你知道call的用法, apply用法反之亦然。 那么 , bind的用法呢?

Bind函数的用途呢?它允许我们将上下文注入一个函数, 该函数返回一个带有更新上下文的新函数。这意味着, 这个变量将是用户提供的变量。当和JS事件一起运行时这是非常有用的。

你应该熟悉在JS中使用这三个函数去组合功能

3.熟悉js作用域(闭包)

JS作用域是一个潘多拉魔盒。数以百计的面试难题有这个概念构成。 有三种作用域:

  • 全局作用域
  • 本地/函数作用域
  • 块级作用域(ES6引进)

全局作用域是我们通常做的那样:

  1. x = 10;
  2. function Foo() {
  3. console.log(x); // Prints 10
  4. }
  5. Foo()

函数作用域生效当你定义一个局部变量时:

  1. pi = 3.14;
  2. function circumference(radius) {
  3. pi = 3.14159;
  4. console.log(2 * pi * radius); // Prints "12.56636" not "12.56"
  5. }
  6. circumference(2);

ES16标准介绍过新块级作用域,限制一个变量作用域带给定的括号块。

  1. var a = 10;
  2. function Foo() {
  3. if (true) {
  4. let a = 4;
  5. }
  6. console.log(a);
  7. }
  8. Foo() // 10, 因为关键词key

函数和条件都被视为块。以上例子应该给出4,因为条件声明已经生效。但是ES6销毁了块级变量的作用域,作用域进入全局。

现在来自神奇的作用域。它可以通过闭包实现。JS闭包是一个返回另一个函数的函数。

如果有人要求你,实现输入一个字符串并逐次返回字符。如果给出一个新的字符串, 需要替换旧字符串。他被简单成为生成器。

  1. function generator(input) {
  2. var index = 0;
  3. return {
  4. next: function() {
  5. if (index < input.length) {
  6. index += 1;
  7. return input[index - 1];
  8. }
  9. return "";
  10. }
  11. }
  12. }
  13. var mygenerator = generator('hello');
  14. mygenerator.next(); // "h"
  15. mygenerator.next(); // "e"
  16. mygenerator = generator('word');
  17. mygenerator.next(); // "w"

此时, 作用域扮演一个重要的角色。一个闭包是返回另一个函数和包裹数据的一个函数。以上字符串生成器便是一个闭包。index的值在多个函数调用中被保存。内部函数可以访问父级函数中定义的变量。这是一个不同的作用域。假设你在二级函数中定义了一个函数, 它可以访问所有父级变量。

JS作用域会给你带来很多问题, 彻底理解它。

4.熟悉this(全局域、函数域、对象域)

JS中, 我们经常把函数和对象组合。假设在浏览器中, 在全局上下文中它涉及window对象。我的意思是, 如果你现在打开浏览器控制台输入this, 改制为true

  1. this === window // true

当程序的上下文和作用域改变时, this随之发生改变。现在观察this在一个局部上下文中:

  1. function Foo() {
  2. console.log(this.a);
  3. }
  4. Foo() // undefined
  5. var food = { a: 'hello--' };
  6. Foo.call(food); // hello--

你可以尝试预测一下输出:

  1. function Roo(){
  2. console.log(this); // prints {}?
  3. }
  4. Roo() // Window {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, frames: Window, …}

不,你还没有获胜。因为this此时是一个全局对象。记住, 无论父级作用域是什么, 它都讲被它的孩子继承。因此, 它打印出了window对象。我们讨论的三个方法实际上用于设置this对象。

现在,this的最后一个类型。在对象中的this, 如下:

  1. var person = {
  2. name: 'Tom',
  3. age: 26,
  4. get identity() {
  5. return {
  6. who: this.name,
  7. howOld: this.age
  8. }
  9. }
  10. }

我仅仅使用getter语法, 它是一个可以作为变量调用的函数。

  1. person.identity; // {who: "Tom", howOld: 26}

因此, 这实际是对象自己。this正如我们前面所提到的不同地方的表现不同。

5.熟悉对象(freeze、seal属性)

可以通过以下方式创建对象:

  1. var marks = {}
  2. var marks = new Object();

我们大多是熟悉的对象如下:

  1. var marks = { physics: 98, maths: 95, chemistry: 91 };

它是一个键值对存储键、值。JS 对象具备的一个特殊属性, 把任何东西可以视为value。这意味着, 我们可以把一个数组、对象、函数作为value来存储。有何不可呢?

你借助JSON的stringify、parse防范可以轻松的把对象转成一个JSON, 相应的可以再转成对象。

  1. JSON.stringify(marks); // "{"physics":98,"maths":95,"chemistry":91}"
  2. JSON.parse('{"physics":98,"maths":95,"chemistry":91}'); // {physics: 98, maths: 95, chemistry: 91}

因此,对于对象你有了解一些什么呢。使用Object.keys很容易迭代对象

  1. var highScore = 0;
  2. for (k of Object.keys(marks)) {
  3. if (marks[k] > highScore) {
  4. highScore = marks[k];
  5. }
  6. }
  7. console.log(highScore); // 98

Object.values 以数组的方式返回对象的值。

其他重要的对象函数:

  • Object.prototype(object)
  • Object.freeze(function)
  • Object.seal(function)

Object.prototype提供更多可以应用的重要函数。如下:

Object.prototype.hasOwnProperty 用于发现一个对象是否存在一个原型或键。

  1. marks.hasOwnProperty('physics'); // true
  2. marks.hasOwnProperty('greek'); // false

Object.prototype.instanceof 评估一个对象是否是特定原型的类型。

  1. function Car(make, model, year) {
  2. this.make = make;
  3. this.model = model;
  4. this.year = year;
  5. }
  6. var newCar = new Car('Jack', 'City', '2008');
  7. console.log(newCar instanceof Car) // true

现在介绍其它两个函数。Object.freeze 允许我们冻结一个对象, 使得存在的属性不能被改变。

  1. var marks = {physics: 98, math: 95, chemisty: 91}
  2. finalizedMarks = Object.freeze(marks); // {physics: 98, maths: 95, chemistry: 91}
  3. finalizedMarks['physics'] = 86;
  4. console.log(marks); // {physics: 98, maths: 95, chemistry: 91}

代码中, physics属相并未被改变。我们可以使用Object.isFrozen来判断,给定对象是否被冻结

  1. Object.isFrozen(finalizedMarks); // true

Object.seal 与freeze有细微差别。前者允许配置属性, 但是不允许添加或删除属性。

  1. var marks = {physics: 98, math: 95, chemisty: 91}
  2. Object.seal(marks); // {physics: 98, math: 95, chemisty: 91}
  3. delete marks.chemisty // false
  4. marks.physics = 95;
  5. console.log(marks); // {physics: 95, math: 95, chemisty: 91}
  6. marks.greek = 86;
  7. console.log(marks); // {physics: 95, math: 95, chemisty: 91}

同样, 可以借助Object.isSealed判断对象是否被密封。

  1. Object.isSealed(marks) // true

6.熟悉原型继承

在传统的js中隐藏着继承的概念, 使用原型技术。你在ES5、ES6中看到的所有new class语法仅仅是底层原型OOP的表层。使用js函数创建一个class.

  1. var animalGroups = {
  2. MAMMAL: 1,
  3. REPTILE: 2,
  4. AMPHIBIAN: 3,
  5. INVERTEBRATE: 4,
  6. };
  7. function Animal(name, type) {
  8. this.name = name;
  9. this.type = type;
  10. }
  11. var dog = new Animal("dog", animalGroups.MAMMAL);
  12. console.log(dog); // Animal { name: 'dog', type: 1 }
  13. var crocodile = new Animal("crocodile", animalGroups.REPTILE);
  14. console.log(crocodile); // Animal { name: 'crocodile', type: 2 }

此时, 我们创建一个类(使用关键词new)。可以使用如下方式对class追加方法。

  1. Animal.prototype.shout = function() {
  2. console.log(this.name+'is'+this.sound+'ing...');
  3. }

你可能有疑问。现在class中没有sound属性。是的。定义一个sound属性几乎没有可能,可以由继承它的子类进行传递。

js中, 如下实现继承。

  1. function Dog(name, type) {
  2. Animal.call(this, name, type);
  3. this.sound = 'bow';
  4. }
  5. // console.log(Dog); // [Function: Dog]

定义一个特殊的函数Dog。为了继承Animal, 需要call传递this和其他参数。如下方式实例化一个Jack。

  1. var pet = new Dog('Jack', animalGroups.MAMMAL);
  2. console.log(pet); // Dog { name: 'Jack', type: 1, sound: 'bow' }
  3. console.log(pet instanceof Dog); // true
  4. console.log(pet instanceof Animal); // false

我们不能在子函数中分配name和type,但是可以调用超级函数Animal并设置属性。。pet拥有其父的(name, type)属性。是否也继承了方法。

  1. pet.shout(); // is not a function

为什么没有继承呢? 因为不能继承父class的方法。如何补救?

  1. Dog.prototype = Object.create(Animal.prototype);
  2. var pig = new Dog('Jack', animalGroups.MAMMAL);
  3. pig.shout(); // Jackisbowing...

现在shout方法是有效的。Object.constructor函数检查对象的class.

  1. console.log(pig.constructor); // [Function: Animal]

检查pig的结果。Animal是父类。这是因为Dog的类

  1. console.log(Dog.prototype.constructor); // [Function: Animal]

输出是Aimal。我们应该设置Dog为其本身, 这样类的所有实例(对象)都应该在类所属的地方给出正确的类名。

  1. Dog.prototype.constructor = Dog;
  2. console.log(Dog.prototype.constructor); // [Function: Dog]

关于原型继承, 我们应该记住以下几条:

  • class 属性使用this绑定
  • class 方法使用prototype对象来绑定
  • 为了继承原型, 使用call函数传递this
  • 为了继承方法, 使用Object.create连接父和子的原型
  • 通设置子class构造函数本身为获取正确的标识。

注意:即使使用新的class语法, 这些事情也会发生。了解这些对你熟悉js有帮助。

js中, call函数和原型对象提供继承

7.熟悉callback和promise

callback 是 一个I/O执行完毕后执行的函数。一个耗时的I/O操作会阻塞代码, 因此在Python/Ruby不被允许。但是js中, 由于允许异步执行, 我们可以提供异步函数来回调。这个例子是由浏览器到服务器的AJAX(XMLHettpRequest)调用,由鼠标、键盘事件生成。如下:

  1. function reqListener() {
  2. console.log(this.responseText);
  3. }
  4. var req = new XMLHttpRequest();
  5. req.addEventListener('load', reqListenter);
  6. req.open('GET', 'http://www.example.org/example.txt');
  7. req.send();

其中, reqListenter是GET请求成功后的回调函数。

Promise 是回调函数的优雅的封装, 使得我们优雅的实现异步代码。此时, 不再过多讨论promise, 虽然对于熟悉Js及其重要。

8.熟悉正则表达

创建正则表达式,有如下两种方式:

  1. var re = /ar/;
  2. var re = new RegExp('ar');

以上正则用于匹配字符串。一旦正则已经定义, 可以使用exec函数匹配字符串。

  1. re.exec('car')
  2. re.exec('cab')

存在复杂的符号, 来实现复杂的正则表达式。

  • 字符正则:\w-字母数字, \d-数字, \D-没有数字
  • 字符正则:[x-y]x-y区间, [^x]没有x
  • 数量正则:+至少一个、?没或多个、*多个
  • 边界正则,^开始、$结尾

例子如下:

  1. // 1
  2. /\d/.exec('qwe') // null
  3. /\d/.exec('2344') // ["2", index: 0, input: "2344", groups: undefined]
  4. /\d/.exec('2cc4') // ["2", index: 0, input: "2cc4", groups: undefined]
  5. // 2
  6. /e+/.exec('qwe') // ["e", index: 2, input: "qwe", groups: undefined]
  7. // 3
  8. [^x]/.exec('xcc4') // ["c", index: 1, input: "xcc4", groups: undefined]
  9. // 4
  10. /^q/.exec('qwe') //["q", index: 0, input: "qwe", groups: undefined]
  11. /e$/.exec('qwe') // ["e", index: 2, input: "qwe", groups: undefined]

除了exec, 还有match、search,以及replace可以返回一个字符串使用正则表达式。但是主体是一个字符串。

  1. 'hello 12345'.match(/\d/) // ["1", index: 6, input: "hello 12345", groups: undefined]
  2. 'hello 12345'.replace(/1/, 'c') // "hello c2345"

正则是个重要的话题, 对于想要简单解决复杂问题的开发人员来说。

正则不单单属于js, 你也可以经常在其他语言中见到

9.熟悉map、reduce和filter

函数式编程是最近讨论的话题。许多编程语言的新版本开始包括lambdas等概念(如:java>7)。 js中, 支持函数式结构已经有很长一段时间。此处, 有三个函数需要我们深入学习。数学函数获取输出并给出返回。一个纯正的函数总是依据输入给出返回,如下讨论的函数属于此类函数。

9.1 map

map函数在js数组中可用。使用这个函数, 我们通过对每一个元素进行转换来获取一个新的数组。一般的js数组map操作如下:

  1. arr.map((elem){
  2. process(elem);
  3. return processedValue;
  4. }); // return a new array

假设,我们最近工作的串行键不需要字符。 我们需要移除。可以使用map去执行相同的操作从而获取结果数字,而不是通过迭代和发现的方式移除字符。

  1. var data = ['2345-34r', '2e345-211', '543-67i4', '346-598'];
  2. var re = /[a-z A-Z]/;
  3. var cleanedData = data.map((elem) => {
  4. return elem.replace(re, '');
  5. });
  6. console.log(cleanedData); // ["2345-34", "2345-211", "543-674", "346-598"]

注意:使用es6的箭头函数语法来定义函数

map接受一个作为参数的函数, 此函数接受一个来自数组的参数。我们需要返回一个处理过的元素, 并应用于数组中的所有元素。

9.2 reduce

reduce函数将一个给定的列表归纳出一个返回。我们通过迭代数组执行相同的操作, 并保存中间结果到一个变量中。此处是一个更简洁的方式进行处理。js的reduce一般使用语法如下:

  1. arr.reduce((accumulator, value, index) => {
  2. process(accumulator, value);
  3. return accumulator;
  4. }, initAccumulator);
  • initAccumulator, 累加器的初始值
  • accumulator, 累加器用于存储中间值和结果值
  • value, 对组对应的元素
  • index, 数组对应的索引号

reduce 的一个实际应用是将一个数组扁平化, 将内部数组转化为单个数组, 如下:

  1. var arr = [[1, 2], [3, 4], [5, 6]]
  2. var flattenedArray = [1, 2, 3, 4, 5, 6]

我们可以通过正常的迭代实现, 神奇的是, 使用reduce会更加简洁。

  1. var arr = [[1, 2], [3, 4], [5, 6]]
  2. var flattenedArray = arr.reduce((a, v) => {
  3. return a.concat(v)
  4. }, [])
  5. flattenedArray // (6) [1, 2, 3, 4, 5, 6]

9.3 filter

filter与map更为接近, 对数组的每个元素进行操作并返回另外一个数组(不同于reduce返回的值)。过滤后的数组可能比原数组长度更短。因为, 我们通过的可能排除 输出数组中更少/零的输入。
filter执行如下:

  1. arr.filter((v) => {
  2. return Boolean;
  3. })

v是数组中的元素, 通过true/false表示过滤元素包括/排除。假设, 我们过滤出以t开始以r结束的元素。

  1. var words = ["tiger", "toast", "boat", "tumor", "track", "bridge"]
  2. var newData = words.filter((str) => {
  3. return str.startsWith('t') && str.endsWith('r');
  4. })
  5. newData // (2) ["tiger", "tumor"]

当你被问到js方面的问题时, 这三个函数应该信手拈来。如你所看到的, 所有三个函数例子并没有改变原数组, 这也证明了这些函数的纯净性。

10. 熟悉错误(异常)处理模式

这部分是许多开发者最不关系的js部分。我了解到很少开发人员讨论错误处理。好的开发方法是小心的将js代码包裹在try/catch周围。

Nicholas C. Zakas, 雅虎的UI工程师, 2018 说过: “经常假设你的代码会失败。事件处理可能不当。记录到服务器。抛出你自己的问题。”

js中, 我们随意码的代码, 可能失败, 如下:

  1. $('button').click(function() {
  2. $.ajax({
  3. url: 'user.json',
  4. success: function(res) {
  5. updateUI(res['posts']);
  6. }
  7. });
  8. });

此时, 我们落入ajax结果总是JSON对象的陷阱。有时, 服务器会崩溃并返回null。这种情况下, null[“posts”]会抛出错误。正确的处理方式如下:

  1. $('button').click(function() {
  2. $.ajax({
  3. url: 'user.json',
  4. success: function(res) {
  5. try {
  6. updateUI(res['posts']);
  7. }
  8. catch(e) {
  9. logError();
  10. flashInfoMessage();
  11. }
  12. }
  13. });
  14. });
  • logError函数打算向服务器报告错误。
  • flashInfoMessage函数使用“当前服务器不可用”等用户友好型方式展示错误信息。

Nicholas说过, 当你感到不可预期的事情发生时手动抛出错误。区分致命和非致命错误。上面的错误与后台服务器挂机相关,是致命的。因此, 我们应该通知客户服务器因为一些原因挂机。这种情况下, 不是致命的, 但是最好通知服务器。为了创建这样的代码, 首先抛出错误, 从window层级捕捉错误事件, 随后记录信息到服务器。

  1. reportErrorToServer = function(error) {
  2. $.ajax({
  3. type: "POST",
  4. url: "http://api.xyz.com/report",
  5. data: error,
  6. success: function(res) {}
  7. });
  8. }
  9. // window error evnet
  10. window.addEvnetListener('error', function(e) {
  11. reportErrorToServer({
  12. message: e.message
  13. });
  14. });
  15. function mainLogic() {
  16. throw new Error("error tip");
  17. }

这个代码需要做如下三件事:

  • 监听window层级错误
  • 出现错误时, API记录
  • 在服务器中记录

你也可以使用新的Boolean函数(es5,es6)在程序之前监测变量的有效性并且不为null、undefined

  1. if (Boolean()) {
  2. // block code
  3. } else {
  4. throw new Error("Custom message");
  5. }

始终考虑错误处理是你自己, 而不是浏览器。

11. 其他(提升机制和事件冒泡)

对于一个js开发者, 以上都是主要概念。了解少数内部细节可是非常有用的。js在浏览器中的工作机制。什么是提升机制和事件冒泡?

11.1 提升机制

提升是 在代码执行过程中将声明的变量推送到程序顶部 的一个过程。

  1. function doSomething(v) {
  2. //
  3. }
  4. doSomething(foo);
  5. var foo;

使用脚本语言类似Python执行以上程序, 会抛出错误。你需要先定义再使用。虽然js是脚本语言, 但是它有提升机制。 在这种机制中, 一个js VM在运行程序是做了以下两件事:

  • 首先,扫描程序收集所有变量和函数的声明和分配内存空间。
  • 通过填充分配的变量来执行程序, 没有分配则填充undefined

以上代码片段中打印“undefined”, 因为最初的扫描中已经收集了变量foo。VM查找所有foo的值。

在 一些地方回抛出错误 和 另外地方使用undefined js环境下的提升机制。学习一些例子来搞清楚提升。

author: 声明可以被提升, 赋值不会。

11.2 事件冒泡

关于事件冒泡, 依据Arun P( 一个高级软件工程)所描述:

“事件冒泡和捕获在HTML DOM API中事件传播的两种方式,当同时注册事件的父子元素中子元素触发事件时。事件的传播方式决定接受事件的元素顺序 ”

关于冒泡, 事件最先由内部元素捕获和处理, 随后传递给父级元素。关于捕获, 顺序相反。我们通常使用addEventListener函数来捆绑事件和事件处理函数

  1. addEventListener('click', handler, useCapture=false);

useCapture是第三个参数的关键词, 默认为false。因此, 冒泡模式是事件由底部向上传递。 反之, 这是捕获模式。

冒泡模式:

  1. <div onClick="divHandler()">
  2. <ul onClick="ulHandler()">
  3. <li id="foo"></li>
  4. </ul>
  5. </div>
  6. <script>
  7. function handler() {}
  8. function divHandler() {}
  9. function ulHandler() {}
  10. documnet.getElementById("foo").addEventListener("click", handler)
  11. </script>

点击li元素, 事件顺序:handler() => ulHandler() => divHandler()

捕获模式:

  1. document.getElementById("foo").addEventListener("click", handler, true)

点击li元素, 事件顺序divHandler => ulHandler() => handler()

以上都是基础的js知识。 正如我最初提及的, 除了这些, 工作经历和知识、准备对你攻克面试都有帮助。保持学习的习惯, 学习最新得技术(es6), 深入js各个方面的学习(如V6、测试等)。一些视频也可以教会你一些知识。最后, 数据结构和算法的准备也必不可少。Oleksii Trekhleb 的算法仓库值得学习

阅读原文

其他blog;