前端面试题集每日一练Day13

183 阅读7分钟

问题先导

  • 浏览器乱码的原因是什么?【html】
  • 如何判断元素是否达到可视区域?【css】
  • z-index的属性在什么情况下会失效?【css】
  • 常用的一些正则匹配规则【js基础】
  • 对JSON的理解【js基础】
  • js脚本延迟加载的方式有哪些【js基础】
  • 说一说mixin和extends的处理逻辑【Vue】
  • 描述下Vue的自定义指令【Vue】
  • 实现日期格式化函数【手写代码】
  • 交换两个数字变量,但不能使用临时变量【手写代码】
  • 代码输出结果(Promise相关)【输出结果】
  • 全排列篇【算法】

知识梳理

浏览器乱码的原因是什么?如何解决?

乱码原因一般都是编码方式与解码方式不匹配导致。网页乱码的原因也是基本是这样,html文档的编码属性由metacharset属性指定。

<head>
	<meta charset="UTF-8">
</head>

一般设置为utf8编码。

如何判断元素是否达到可视区域?

当存在滚动条时,元素可能会在可视区域外,对于懒加载的页面,让图片出现在可视区域再去请求资源是一种有效节约页面加载时间的做法。

以一张图片为例:

image-20210611231130458.png

很明显,当元素的可距文档顶部的高度大于滚动条高度,又小于滚动条高度 + 浏览器可视区域高度时,该元素就处于可视区域内:

/**
 * 判断元素是否在可视区域内
 * @param {Element} e 
 */
function isInnerView(e) {
    const scrollTop = document.body.scrollTop || document.documentElement.scrollTop; // 文档滚动过的高度
    const innerHeight = window.innerHeight; // 浏览器可视区域高度
    const offsetTop = e.offsetTop; // 元素距离文档顶部的高度
    return offsetTop < scrollTop + innerHeight || offsetTop - e.clientHeight > scrollTop;
}

z-index属性在什么情况下会失效?

z-index 属性设定了一个定位元素及其后代元素或 flex 项目的 z-order。 当元素之间重叠的时候, z-index 较大的元素会覆盖较小的元素在上层进行显示。

简单来说,z-index可以设置元素的堆叠层级,数值越大层级越高,越处于上层。

有些情况下z-index会失效:

  • 父元素定位属性为relative时,如果子元素z-index属性失效,需要设置子元素的position为:absolutestatic
  • 元素定位属性需要设置为static属性,可选值有:relativeabsolutefixed
  • 元素设置了float导致失效:移除float属性并使用display: inline-block代替。

常用的一些正则匹配规则

正则表达式是用于匹配字符串中字符组合的模式。在 JavaScript中,正则表达式也是对象。这些模式被用于 RegExp 的 exec 和 test 方法, 以及 String 的 match、matchAll、replace、search 和 split 方法。本章介绍 JavaScript 正则表达式。

有两种方式可以申明一个正则对象:

const reg = /\d+/; // 正则字面量
const reg2 = new RegExp('\d+'); // 正则对象

正则的使用一般有以下几种方式:

  • 断言

    • ^:匹配输入的开头。

    • $:匹配输入的结尾。

    • \b:匹配一个单词的边界。

      const beforeM = /\bm/ // 匹配以'm'为左边界的单词
      const afterON = /on\b/ // 匹配以'on'为右边界的单词
      
    • \B:匹配非单词边界。处理和\b相反的情况。

    • x(?=y):向前断言。当x后跟y时才匹配x,换句话就是匹配y之前的x

    • x(?!y):向前否定断言。

    • (?<=y)x:向后断言。

    • (?<!y)x:向后否定断言。

  • 字符类

    • .:匹配除换行终止符之外的单个字符。
    • \d:匹配单个数字,相当于[0-9]
    • \D:匹配非数字,相当于[^0-9]
    • \w:匹配基本拉丁字母中的任何字母数字字符,相当于[a-zA-Z0-9]
    • \W:匹配非基本拉丁字母
    • \s:匹配单个空白字符
    • \S:匹配单个非空白字符

    等等

  • 组和范围

    • x|y:匹配x或y
    • [xyz]:匹配组内的字符,相当于x|y|z
    • [^xyz]:匹配取反组
    • (x):括号的目的是匹配并记匹配到的值,如果匹配到多个值,将会缓存到RegExp对象的$1$n中。
    • (?:x),虽然使用了括号,但不记录匹配组。
    • \n:其中n是一个正整数。对正则表达式中与n括号匹配的最后一个子字符串的反向引用(计算左括号)。例如,/apple(,)\sorange\1/ 匹配 “apple,orange,cherry,peach” 中的 "apple,orange,", 其中 \1 引用了 之前使用 () 捕获的
  • 量词

    • x*,匹配0次或多次,相当于x{0,}
    • x+,匹配1次或多次,相当于x{1,}
    • x?,匹配0次或1次,相当于x{0,1}
    • x{n},匹配n次
    • x{m,n},匹配[m, n]之间次数的字符x

    注意,*+是贪婪的,只要符合有就会一直往后搜索。

  • Unicode转义

    参考:Unicode property escapes

参考:

对JSON的理解

JavaScript Object Notation (JSON) 是一种数据交换格式。尽管不是严格意义上的子集,JSON 非常接近 JavaScript 语法的子集。

许多编程语言都支持 JSON,尤其是 JavaScript,它在网站和浏览器扩展应用广泛。

JSON 可以表示数字、布尔值、字符串、null、数组(有序序列),以及由这些值组成的对象(字符串与值的映射)。JSON 不支持复杂的数据类型(函数、正则表达式、日期等)。日期对象默认会转化为 ISO 格式的字符串,因此信息不会完全丢失。

如果你需要使用 JSON 来表示复杂的数据类型,请在它们转化为字符串值。

对于json字符串,可以使用JSON.parse转换为js的对象,而使用JSON.stringify可以将js普通对象序列化为json字符串。

js脚本延迟加载的方式有哪些?

script标签使用的是src属性引用,默认是阻塞式加载的,也就是加载的时候回阻塞页面渲染。

但我们可以使用asyncdefer属性来指定脚本为异步加载,虽然两个属性都是异步加载,但defer还要延迟加载的功能,也就是文档解析完成后再加载脚本,而async会和页面解析一起并行加载解析。

此外,我们还可以:

  • 动态创建<script>:监听文档的加载,当某些事件触发后再插入script标签,也就达到了延迟加载的目的
  • 使用setTimeout进行脚本的延迟加载,不过一般不推荐,因为不好控制加载时机。

说一说mixin和extends的处理逻辑

混入 (mixin) 提供了一种非常灵活的方式,来分发 Vue 组件中的可复用功能。一个混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被“混合”进入该组件本身的选项。

var mixin = {
  created: function () { console.log(1) }
}
var vm = new Vue({
  created: function () { console.log(2) },
  mixins: [mixin]
})

默认情况下,mixin的混入以组件数据优先,即遇到属性冲突时,以组件数据为准,而对于同名钩子函数,则会加入一个数组中,当触发时,两个函数都会被调用

当然,我们还可以自定义合并策略:

Vue.config.optionMergeStrategies.myOption = function (toVal, fromVal) {
  // 返回合并后的值
}

extends为组合,允许声明扩展另一个组件 (可以是一个简单的选项对象或构造函数),而无需使用 Vue.extend。这主要是为了便于扩展单文件组件。

var CompA = { ... }

// 在没有调用 `Vue.extend` 时候继承 CompA
var CompB = {
  extends: CompA,
  ...
}

简单来说,mixin主要用于混合复用组件实例的选项,而extends是继承的意思,主要用于单组件的复用。

两者的目的都是复用组件,两者均通过 mergeOptions 方法实现合并,不同之处在于mixin接受一个混入对象数组,而extends主要是为了扩展单文件组件,接受一个对象或构造函数。

参考:

描述下Vue的自定义指令

除了核心功能默认内置的指令 (v-modelv-show),Vue 也允许注册自定义指令。注意,在 Vue2.0 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令。

注册全局指令使用Vue.derective()方法,注册局部指令使用directives属性来描述。

指令主要是对DOM的更新和数据更新做统一处理,重点就是DOM的更新逻辑,指令一般接收Vue实例数据,然后根据数据响应式更新DOM,如何更新DOM就是指令要做的事,Vue为指令提供了以下钩子函数,方便在适当的时机更新DOM:

  • bind:指令绑定到元素时调用,一般用于做一次性的初始化设置。
  • inserted:元素被插入父元素时调用(元素不一定在文段中,可以是虚拟节点)
  • update:所在组件的VNode更新时调用,指令值可能发生了变化,也可能没有
  • componentUpdated:指令所在组件的VNode及其子VNode全部更新后调用
  • unbind:指令与元素解绑时调用

实现日期格式化函数

目前Date对象没有提供日期输出格式转换函数,类似这样:

dateFormat(new Date('2020-12-01'), 'yyyy/MM/dd') // 2020/12/01
dateFormat(new Date('2020-04-01'), 'yyyy/MM/dd') // 2020/04/01
dateFormat(new Date('2020-04-01'), 'yyyy年MM月dd日') // 2020年04月01日
dateFormat(new Date('2020-04-01'), "yyyy-MM-dd hh:mm:ss.S") // 2020-04-01 08:09:04.423   

下面的字符表示日期相关数据替代符。

  • y:年
  • M:月
  • d:日
  • h:时
  • m:分
  • s:秒
  • S:毫秒
  • w:星期
/**
 * 日期格式转换
 * @param {string} fmt 格式串
 * @returns string
 */
Date.prototype.formate = function(fmt) {
    if(!fmt) {
        return this.formate('yyyy-MM-dd');
    };
    const formateMap = {
        'y': this.getFullYear,
        'M': this.getMonth,
        'd': this.getDate,
        'h': this.getHours,
        'm': this.getMinutes,
        's': this.getSeconds,
        'S': this.getMilliseconds,
        'w': this.getDay,
    }
    const keys = Object.keys(formateMap);
    for(let i = 0; i < keys.length; i++) {
        const char = keys[i];
        if(new RegExp(`(${char}+)`).test(fmt)) {
            let date = formateMap[char].apply(this);
            if(char === 'M') {
                date += 1;
            } else if(char === 'w' && date === 0) {
                date = 7;
            }
            const matchStr = RegExp.$1;
            fmt = fmt.replace(matchStr, date.toString().substring(date.toString().length - matchStr.length));
            i--;
        }
    }
    return fmt;
}

console.log(new Date().formate('yyyy-MM-dd hh:mm:ss <星期w>'))

交换两个数字变量,但不能使用临时变量

可以利用两个数字的和差运算巧妙转换:

let a = 2;
let b = 3;
a += b;
b = a - b;
a = a - b;
console.log(a, b)

同样的,使用乘除法也可,但需要保证b不能为0,还可以使用抑或的自反性,但是只能对整数进行抑或运算:

a = a ^ b
b = a ^ b
a = a ^ b

代码输出结果(Promise相关)

代码片段:

function runAsync (x) {
  const p = new Promise(r => setTimeout(() => r(x, console.log(x)), 1000))
  return p
}
Promise.race([runAsync(1), runAsync(2), runAsync(3)])
  .then(res => console.log('result: ', res))
  .catch(err => console.log(err))

本题考察Promise.race的执行逻辑,race有竞赛的含义,这个方法也正是如此:让迭代对象中的Promise对象并行执行,最先改变状态值的就会被直接返回。

代码片段中,三个runAsync并行执行,首先都会三个Promise的回调先进入异步执行队列(settimeOut是宏任务),由于runAsync(1)最先进入队列,因此Promise返回的Promise状态值为1,执行then回调的微任务。

1
result: 1
2
3

注意这里,并不是直接打印1,2,3再打印result: 1,这是js事件循环中的宏任务和微任务执行顺序决定的。

首先说一下异步,异步就是主线程之外的执行线程,宏任务和微任务与是不是异步无关,一个判断执行任务时宏任务还是微任务的逻辑为:宏任务是由宿主发起的,而微任务由JavaScript自身发起。

比如setTimeout就是浏览器发起的,因此是异步宏任务,而Promise.then是js内部发起的,就是异步微任务了。

所以上面三个setTimeout的回调都将进入异步宏任务队列,当第一个宏任务执行结束后,Promise状态更新,Promise.then的回调加入微任务待执行队列中,由于宏任务和微任务是交替执行的,当第一个宏任务执行完毕,刚好就直接执行了刚加入的Promise.then的微任务,然后才会执行还在队列中的后两个setTimeout宏任务。

代码片段:

function runAsync(x) {
  const p = new Promise(r =>
    setTimeout(() => r(x, console.log(x)), 1000)
  );
  return p;
}
function runReject(x) {
  const p = new Promise((res, rej) =>
    setTimeout(() => rej(`Error: ${x}`, console.log(x)), 1000 * x)
  );
  return p;
}
Promise.race([runReject(0), runAsync(1), runAsync(2), runAsync(3)])
  .then(res => console.log("result: ", res))
  .catch(err => console.log(err));

同上一题,四个异步并行执行,renReject(0)最先执行,状态直接返回,并触发微任务回调:

0
Error: 0
1
2
3

全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

输入:nums = [0,1]
输出:[[0,1],[1,0]]

对于这种输出所有结果的题,一般都可以用回朔法来求解。回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。

随便写几个输出结果,就不难发现,全排列的结果就是数组中的数字在每个位置都出现一次,我们可以每个数字看作树的一个节点,树的下一层表示一种可选数字,最终从根节点到达每个叶子节点的路径就是每一种可能出现答案。

image-20210613191618719.png

了解了全排列可以用树结构来存储,剩下的就是树的遍历了,树遍历一般用广度优先遍历(BFS)和深度优先遍历(DFS)两种方法。

广度优先:

广度优先就是一层一层遍历,由于不到最后一层无法直接得到答案,所以遍历一层需要记录一层的数据,遍历下一层的逻辑是:未遍历的数字即为下一层的所有节点数字。所以我们需要记录两个数据:到当前结点已经遍历的数字集合tree,未遍历的数字集合filter。当未遍历数字为空时,说明结点已经到了叶子节点,收集到的tree就是一个答案。初始状态只有一个根节点,状态为:tree为空数字,filternums,然后开始层次遍历。

/**
 * 返回数组的全排列结果
 * @param {number[]} nums 
 * @returns number[][]
 */
function permute(nums) {
    if(nums.length === 0) {
        return [];
    }
    const res = [];
    bfs([{
        tree: [],
        filter: nums,
    }], res);
    return res;
    /**
     * 广度优先遍历
     * @param {{tree: any[]; filter: number[]}[]} layerArr 节点层数据
     * @param {number[][]} res
     */
    function bfs(layerArr, res) {
        while(layerArr.length) {
            const item = layerArr.shift();
            if(item.filter.length == 0) {
                res.push(item.tree);
                continue;
            }
            item.filter.forEach((n, i, _base) => {
                layerArr.push({
                    tree: [].concat(item.tree, [n]),
                    filter: _base.filter(m => m != n),
                });
            });
        }
    }
}

深度优先:

回溯法采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。

深度优先遍历的思想一致和回朔法一致,一直遍历到不能再往下为止,然后回到根节点继续向下遍历。每次遍历我们做的事情都是一致的,也就是迭代,本题中向下遍历就是向下选择一个可选数字,直到所有数字都被选择完毕,也就是遍历的层数等于数组的长度,然后我们还需要记录已遍历过的数字,以便于判断下一个可选数字是否已被选。

总结来说就是,使用迭代的方式进行深度优先遍历,遍历的终止条件是层数达到数组长度,并且记录已遍历数字。

/**
 * 返回数组的全排列结果
 * @param {number[]} nums 
 *@returns number[][]
 */
function permute(nums) {
    if(nums.length === 0) {
        return [];
    }
    const res = [];
    dfs(nums, [], [], res);
    return res;
    /**
     * 深度优先遍历
     * @param {number[]} nums 
     * @param {number[]} arr 
     * @param {boolean[]} used 
     * @param {number[][]} res
     */
    function dfs(nums, arr, used, res) {
        const len = nums.length;
        if(arr.length === len) {
            // 拷贝一份
            res.push([...arr]);
        }
        for(let i = 0; i < len; i++) {
            if(!used[i]) {
                // 记录状态后继续递归遍历
                arr.push(nums[i]);
                used[i] = true;
                dfs(nums, arr, used, res);
                // 回朔上一次的操作状态
                arr.pop();
                used[i] = false;
            }
        }
    }
}

tips:

动态规划只需要求我们评估最优解是多少,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果; 回溯算法可以搜索得到所有的方案(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高。=

一般来说,结果都是从根节点到叶子节点的路径集合,层次遍历由于是一层一层遍历,往往需要不断存储节点的层数据,当遍历过后就需要移除该节点的数据,更新为下一节点的数据,一般使用队列来存储数据:从当前结点取出数据,再更新为下一层的数据,也就是不断地出出进进,由当前层节点得到下一层节点的数据,知道叶子节点,然后输出结果。

而深度优先是一头走到黑,找到一个答案再继续回来找第二个答案,因此不需要记录太多数据,所以一般使用栈结构来存储数据,先不断将数据压入栈中,一次遍历结束后取出,栈空又继续压入。深度优先虽然空间效率高,但不断地迭代实际上是做了很多判断操作的(比如标记为已遍历,再从中找到未遍历的),也就多了很多执行步骤。