前端面试题集每日一练Day11

493 阅读14分钟

问题先导

  • canvassvg的区别?【html】
  • css有哪些优化可以提高性能?【css】
  • css预处理器/后处理器是什么?为什么要使用它们?【css】
  • new操作符的创建原理?【js基础】
  • MapObject的区别?【js基础】
  • WeakMapMap又有什么区别?【js基础】
  • Vue是如何实现原生方法的监听的?【Vue】
  • 柯里化函数实现【手写代码】
  • Ajax请求实现【手写代码】
  • 输出结果(Promise相关)【输出结果】
  • 二叉搜索树中第K小的元素【算法】

知识梳理

canvassvg的区别?

Canvas API 提供了一个通过JavaScriptHTMLcanvas元素来绘制图形的方式。它可以用于动画、游戏画面、数据可视化、图片编辑以及实时视频处理等方面。Canvas API主要聚焦于2D图形。而同样使用<canvas>元素的 WebGL API 则用于绘制硬件加速的2D和3D图形。

SVG,Scalable Vector Graphics的缩写,即可缩放矢量图形,是W3C XML的分支语言之一,用于标记可缩放的矢量图形。目前SVG在Firefox、Opera、Webkit浏览器、IE等浏览器中已经部分实现。

两者都可用于绘制矢量图形,区别在于绘制方式不同,Canvas是基于脚本在canvas画布上进行绘制,但svg是XML标准语言,也就是用标签来绘制图形,而且存在于BOM中。

也就是说svg基于XML元素进行图形描述,图形之间比较独立,支持事件处理,由于基于XML,DOM可能会过度使用,渲染也就较慢。

而Canvas是基于像素来绘制图形的,而且使用WebGL启用硬件加速,渲染效率较高,但整个画布融为一体,如果需要修改某处显示,就需要整个画布重新渲染。

两者各有优缺点,svg适合小型图像绘制,兼容性也更好,而canvas适合频繁变更图像的绘制。

参考:

css有哪些优化可以提高性能?

  • 加载性能
    • css压缩:减少css体积,减少传输带宽。
    • 尽量使用css单一样式:比如只需要修改上下边距时,只使用margin-bottommargin-top的效率会比统一使用margin要快一些。
    • 减少使用@import而改用link,因为前者会等待页面加载结束后再加载,而后者是并行加载。
  • 选择器性能
    • 避免使用通配符
    • 优先考虑类选择器代替元素选择器
    • 尽量少地使用后代选择器,最高不要超过三层,可以用类来组合这些元素
    • 对于可继承的属性,没必要再重复指定规则,除非不希望使用继承值
  • 渲染性能
    • 谨慎使用高性能属性:浮动、定位。因为页面回流一般就需要重新计算这些属性的准确位置
    • 尽量减少页面的回流、重绘
    • 属性值为0时,不加单位,可以减少运算次数
    • 小图标使用图片精灵或者base64编码加载
    • 不滥用web字体,因为中文网站对webFonts可能很陌生,需要下载体积庞大的字体数据,从而阻塞页面渲染
  • 可维护性
    • 相同属性的样式抽离出来,使用class来统一控制
    • 样式与html分离,便于管理

css预处理器/后处理器是什么?为什么要使用它们?

预处理器, 如:lesssassstylus,用来预编译sass或者less,增加了css代码的复用性。层级,mixin, 变量,循环, 函数等对编写以及开发UI组件都极为方便。

后处理器, 如: postCss,通常是在完成的样式表中根据css规范处理css,让其更加有效。目前最常做的是给css属性添加浏览器私有前缀,实现跨浏览器兼容性的问题。

css预处理器为css增加一些编程特性,无需考虑浏览器的兼容问题,可以在CSS中使用变量,简单的逻辑程序,函数等在编程语言中的一些基本的性能,可以让css更加的简洁,增加适应性以及可读性,可维护性等。其它css预处理器语言:Sass(Scss), Less, Stylus, Turbine, Swithch css, CSS Cacheer, DT Css

使用原因:

  • 结构清晰, 便于扩展
  • 可以很方便的屏蔽浏览器私有语法的差异
  • 可以轻松实现多重继承
  • 完美的兼容了CSS代码,可以应用到老项目中

new操作符的创建原理?

new运算符创建具有构造函数的对象实例。

new关键字会执行如下操作:

  1. 创建一个空对象

  2. 链接该对象的constructor到构造函数本身,这个值也等于构造函数原型上的constructor。比如({}).constructor === Object.prototype.constructor结果为true,同时Object.prototype.constructor === Objec也等于本身。

    这一步我们也可以理解为让该对象继承构造函数原型对象的属性

  3. 调用构造函数,并让新对象作为this上下文

  4. 如果函数没有返回值,则返回这个新对象,有返回值则直接返回该值

我们可以自己实现一下:

/**
 * new操作符实现
 * @param {Function} constructor 构造函数
 * @param  {...any} args 参数
 */
function instanceFactory(constructor, ...args) {
    // #使用构造函数原型来创建一个新对象
    const instance = Object.create(constructor.prototype);
    // #让新对象作为 this 上下文
    const res = constructor.apply(instance, args);
    return (typeof res === 'function' || typeof res === 'object') ? res : instance;
}

Map和Object的区别?

Map 对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者原始值) 都可以作为一个键或一个值。

Objects Maps 类似的是,它们都允许你按键存取一个值、删除键、检测一个键是否绑定了值。因此(并且也没有其他内建的替代方式了)过去我们一直都把对象当成 Maps 使用。不过 MapsObjects 有一些重要的区别,在下列情况里使用 Map 会是更好的选择:

MapObject
意外的键Map 默认情况不包含任何键。只包含显式插入的键。一个 Object 有一个原型, 原型链上的键名有可能和你自己在对象上的设置的键名产生冲突。注意: 虽然 ES5 开始可以用 Object.create(null) 来创建一个没有原型的对象,但是这种用法不太常见。
键的类型一个 Map的键可以是任意值,包括函数、对象或任意基本类型。一个Object 的键必须是一个 String 或是Symbol
键的顺序Map 中的 key 是有序的。因此,当迭代的时候,一个 Map 对象以插入的顺序返回键值。一个 Object 的键是无序的注意:自ECMAScript 2015规范以来,对象确实保留了字符串和Symbol键的创建顺序; 因此,在只有字符串键的对象上进行迭代将按插入顺序产生键。
SizeMap 的键值对个数可以轻易地通过size 属性获取Object 的键值对个数只能手动计算
迭代Mapiterable 的,所以可以直接被迭代。迭代一个Object需要以某种方式获取它的键然后才能迭代。
性能在频繁增删键值对的场景下表现更好。在频繁添加和删除键值对的场景下未作出优化。

相比之下,mapobject更像一个真正的映射表,支持的功能也更多。

参考:

Map和WeakMap的区别?

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。WeakMap 的 key 只能是 Object 类型。

new WeakMap([iterable])

Iterable 是一个数组(二元数组)或者其他可迭代的且其元素是键值对的对象。每个键值对会被加到新的 WeakMap 里。null 会被当做 undefined。

总的来说,WeekMapMap除了键必须为对象(也就是引用类型数据)外,没有太大区别。

为什么要叫弱引用,我们先来看一个现象:

let obj = {
    name: 'Jinx',
    age: 23,
};
const map = new Map();
map.set(obj, true);
obj = null;
console.log(obj)

最开始obj的赋值引用就是强引用,当我们设置obj=null时,先前被设置的强引用被移除,正常情况下这个obj对象在没有被引用的时候就会被垃圾回收(GC),但由于map中的键引用了这个对象,因此不能被收回。但是如果我们把这个对象换成WeakMap,这里的键引用就是弱类型,当垃圾回收时这个对象就会被回收,而这个弱引用对象中的键值也会消失。

相比之下,原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。

正由于这样的弱引用,WeakMap 的 key 是不可枚举的 (没有方法能给出所有的 key)。如果key 是可枚举的话,其列表将会受垃圾回收机制的影响,从而得到不确定的结果。因此,如果你想要这种类型对象的 key 值的列表,你应该使用 Map)。

为什么要引入WeakMap可以看这篇官方说明:Why WeakMap?

参考:

Vue中如何实现对数组原生方法的监听?

在Vue中,对响应式处理利用的是Object.defineProperty对数据进行拦截,而这个方法并不能监听到数组内部变化,数组长度变化,数组的截取变化等,所以需要对这些操作进行hack,也就是重写了原生方法,增加了响应式处理逻辑,让Vue能监听到其中的变化。

// 缓存数组原型
const arrayProto = Array.prototype;
// 实现 arrayMethods.__proto__ === Array.prototype
export const arrayMethods = Object.create(arrayProto);
// 需要进行功能拓展的方法
const methodsToPatch = [
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse"
];
/**
 * Intercept mutating methods and emit events
 */
methodsToPatch.forEach(function(method) {
  // 缓存原生数组方法
  const original = arrayProto[method];
  def(arrayMethods, method, function mutator(...args) {
    // 执行并缓存原生数组功能
    const result = original.apply(this, args);
    // 响应式处理
    const ob = this.__ob__;
    let inserted;
    switch (method) {
    // push、unshift会新增索引,所以要手动observer
      case "push":
      case "unshift":
        inserted = args;
        break;
      // splice方法,如果传入了第三个参数,也会有索引加入,也要手动observer。
      case "splice":
        inserted = args.slice(2);
        break;
    }
    // 
    if (inserted) ob.observeArray(inserted);// 获取插入的值,并设置响应式监听
    // notify change
    ob.dep.notify();// 通知依赖更新
    // 返回原生数组方法的执行结果
    return result;
  });
});

柯里化函数实现

在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。函数柯里化是函数式编程的基础概念。

科里化的关键在于判断传入的参数数量,如果大于等于函数参数数量,则直接调用函数执行,否则就将传入的参数缓存起来,然后返回一个接收剩余参数的函数

/**
 * 函数柯里化
 * @param {Function} fn 
 * @param  {...any} arg 
 * @returns *
 */
function curry(fn, ...arg) {
    return arg.length >= fn.length ? fn.apply(this, arg) : curry.bind(this, fn, ...arg);
}

Ajax请求的实现

AJAX是 Asynchronous JavaScript and XML 的缩写,指的是通过 JavaScript 的 异步通信,从服务器获取 XML 文档从中提取数据,再更新当前网页的对应部分,而不用刷新整个网页。

Ajax不是一种技术,而是一种术语,也就是一些现有技术的合集:异步、JavaScript、XML和XMLHTTPRequest,核心就是XMLHTTPRequest对象。XMLHTTPRequest对象用于和服务器通信,关键点在于通信过程是异步的,也就是页面无需等待通信结果,而通信内容就是我们需要的XML等内容,实际上,JSON是比较通用也是最常用的ajax交互数据,当异步拿到这些数据,再来更新页面,用户无需等待这个过程交互体验就很良好。

总结一下就是:

  1. XMLHTTPRequest像服务器发起异步请求:使用open函数初始化请求,设置完相关请求参数后,使用send发起请求。
  2. 浏览器主线程继续执行别的任务
  3. 服务器返回请求数据,onreadystatechange 函数接收到状态变更信息,执行请求回调
  4. 如果请求失败,使用onerror 函数可以监听到对应异常信息

根据这个逻辑我们就能写一段Ajax请求的逻辑:

const url = '/server';
const xhr = new XMLHTTPRequest();
// 初始化请求信息:设置请求方法、请求路径和是否异步
xhr.open('GET', url, true);
// 设置响应类型、请求头信息
xhr.responseType = 'json';
xhr.setRequestHeader("Accept", "application/json");
// 监听状态变化
xhr.onreadystatechange = function(){
    if(this.readSate !== 4){
     	return;  
    }
    if (this.status === 200) {
    	console.log('responseData:', this.response);
  	} else {
    	console.error(this.statusText);
  	}
}
// 设置请求失败回调
xhr.onerror = function() {
   console.error(this.statusText);
};
// 发送 Http 请求
xhr.send(null);

提取上述变量,就可以用Promise对象封装出更方便的ajax函数。

参考:

输出结果(Promise相关)

代码片段:

Promise.resolve('1') // Promise1
  .then(res => {
    console.log(res)
  })
  .finally(() => {
    console.log('finally')
  })
Promise.resolve('2') // Promise2
  .finally(() => {
    console.log('finally2')
  	return '我是finally2返回的值'
  })
  .then(res => {
    console.log('finally2后面的then函数', res)
  })

本题考查Promise.finally的执行逻辑,也就是无论Promise的状态成功与否都会调用,返回的新Promise对象继承自上一个Promise的状态和值。

  1. Promise1的then回调加入微任务队列
  2. Promise2的finally回调加入微任务队列
  3. 执行微任务:打印1,2步骤的回调
  4. promise1.then的finally回调加入微任务队列
  5. promise2.finally的then回调加入微任务队列
  6. 执行微任务:打印4,5的回调
1
finally2
finally
finally2后面的then函数 2

代码片段:

Promise.resolve('1')
  .finally(() => {
    console.log('finally1')
    throw new Error('我是finally中抛出的异常')
  })
  .then(res => {
    console.log('finally后面的then函数', res)
  })
  .catch(err => {
    console.log('捕获错误', err)
  })

本题考查Promise.finally回调抛出异常的处理逻辑:不再继承上一个Promise的状态和值,直接返回一个已拒绝状态的Promise

finally1
捕获错误 Error: 我是finally中抛出的错误

二叉搜索树中第K小的元素

给定一个二叉搜索树的根节点 root ,和一个整数 k ,请你设计一个算法查找其中第 k 个最小元素(从 1 开始计数)。

输入:root = [3,1,4,null,2], k = 1
输出:1

输入:root = [5,3,6,2,4,null,null,1], k = 3
输出:3

寻找第k大的元素,一般的做法是排完序,然后就能直接得到答案了。本题我们可以使用有优化的排序算法,可以不完全排序结束,就能得到答案,比如:快排(二分法)、堆排序(利用大小根堆)。

树的遍历有深度优先和广度优先两种,但本题的树不是一般的树,是二叉搜索树(BST),BST是满足这样性质的树:

  1. 左子树所有节点均小于根节点
  2. 由子树所有节点均大于根节点
  3. 左右子树也是BST树

注:和堆结构不同在于,堆结构是完全二叉树,且堆的节点是和左右孩子比较,不是和左右子树所有节点比较。

这种树结构有一个特点就是中序遍历就是升序排序,所以我们中序遍历后就能得到一个升序排列的数组。

递归中序遍历

let kthSmallest = function(root, k) {
    let res = null
    let inOrderTraverseNode = function(node) {
        if(node !== null && k > 0) {
            // 先遍历左子树
            inOrderTraverseNode(node.left)
            // 然后根节点
            if(--k === 0) {
                res = node.val
                return
            }
            // 再遍历右子树
            inOrderTraverseNode(node.right)
        }
    }
    inOrderTraverseNode(root)
    return res
}

递归需要遍历完整颗树,实际上,遍历是升序的过程,当存储升序的数组长度变为k时,这个数其实就是我们需要的数,后面的数都比这个数大,就没必要继续遍历了。

迭代中序遍历

let kthSmallest = function(root, k) {
    let stack = []
    let node = root
    
    while(node || stack.length) {
        // 遍历左子树
        while(node) {
            stack.push(node)
            node = node.left
        }
      
        node = stack.pop()
        if(--k === 0) {
            return node.val
        }
        node = node.right
    }
    return null
}