前端面试题集每日一练Day6

问题先导

  • HTML5的离线储存怎么使用,它的工作原理是什么?【html】
  • 伪元素和伪类的区别?【css】
  • requestAnimationFrame的理解?【css】
  • isNaNNumber.isNaN的区别?【js】
  • ==操作符的转换原则?【js】
  • Vue实例中的computedwatchmethods有什么区别?【vue】
  • Vue中的slot是什么?有什么用处?原理是什么?【cue】
  • 手写Object.create【手写代码】
  • 手写instanceof【手写代码】
  • 输出结果判断【输出结果】
  • 字符串相加【算法】

知识梳理

HTML5的离线储存怎么使用,它的工作原理是什么?

离线缓存的目的是当浏览器处于离线状态或网络连接较慢时,利用应用程序缓存机制让web程序能在离线状态下正常访问。使用离线缓存技术一般是为了让用户在:

  • 离线状态也能正常访问
  • 提高访问速度
  • 减轻服务器响应压力

实现离线缓存,目前主要使用 Service Workers方案,而文后提到的manifest属性方案已弃用,建议不要使用,这里只是作为了解。

历史方案:利用manifest属性实现App Cache(已弃用)

一般具有window.applicationCache对象的浏览器才支持该特性。该方案已弃用,了解即可。

启用应用程序缓存的做法是在应用程序页面的html标签上增加manifest属性来引入缓存配置文件

<html manifest="example.appcache"> 
  ...
</html>
复制代码

然后编辑.appcache文件用于描述如何启动缓存,下面是完整缓存清单

# 缓存清单:后续列出的文件会在第一次下载完毕后缓存起来
CACHE MANIFEST
index.html
cache.html
style.css
image1.png

# 缓存白名单:列出不需要缓存的文件
NETWORK:
network.html

# 定了一个后备页面,当资源无法访问时,浏览器会使用该页面。
# 该段落的每条记录都列出两个 URI—第一个表示资源,第二个表示后备页面。
FALLBACK:
/ fallback.html
/userpage user_404.html
复制代码

CACHE MANIFEST,NETWORK 和 FALLBACK 段落可以以任意顺序出现在缓存清单文件中,并且每个段落可以在同一清单文件中出现多次。

缓存清单写好了,我们还需要按时更新,否则一直使用缓存会导致界面内容老旧,更新缓存使用JavaScript脚本来更新:

  • indow.applicationCache.status记录缓存状态
  • window.applicationCache.update()手动更新
function onUpdateReady() {
  alert('found new version!');
}
window.applicationCache.addEventListener('updateready', onUpdateReady);
if(window.applicationCache.status === window.applicationCache.UPDATEREADY) {
  onUpdateReady();
} else {
   window.applicationCache.update()
}
复制代码

Service Workers

之前的尝试 — AppCache — 看起来是个不错的方法,因为它可以很容易地指定需要离线缓存的资源。但是,它假定你使用时会遵循诸多规则,如果你不严格遵循这些规则,它会把你的APP搞得一团糟。关于APPCache的更多详情,请看Jake Archibald的文章: Application Cache is a Douchebag.

Service worker 最终要去解决这些问题。虽然 Service Worker 的语法比 AppCache 更加复杂,但是你可以使用 JavaScript 更加精细地控制 AppCache 的静默行为。

使用的前提

在支持service workers的浏览器中,很多特性可能默认是关闭的,如果该浏览器支持该特性但相关代码不生效,你需要先开启浏览器的相关设置。比如chrome:访问 chrome://flags并开启 experimental-web-platform-features

此外,出于页面安全考虑,services workers要求必须在HTTPS协议下才能使用。

使用细节

service worker有点类似于web worker,代码也是在独立于主线程的worker中运行。

如果'serviceWorker' in navigator说明浏览器支持Service Worker,使用该特性一般需要:

  • 注册worker:navigator.serviceWorker.register
  • 安装和激活:定义缓存文件。使用install事件进行安装监听,该事件会在注册之后触发。
  • 劫持请求:当页面使用到缓存的资源时,会触发fetch事件,这时候你就可以告诉service worker该如何使用该资源,你可以在请求发起前定义响应内容,这也是Service Worker的精细之处,你可以返回缓存到的资源,也可以重新发起网络请求而不使用缓存,或者网络不可用时返回一些准备好的意外页。
  • 更新缓存:简单的使用版本编号就可以控制缓存版本。
  • 删除旧缓存:还有个 activate 事件,当之前版本还在运行的时候,一般被用来做些会破坏它的事情,比如摆脱旧版的缓存。

具体使用细节请参考使用 Service Workers。这是一个简单的使用案例: sw-test

参考:

伪元素和伪类的区别

  • 伪元素:在元素的前后插入额外的内容或样式,由于这些新增的元素是真实存在的,但又不是文档源码中的,因此称为伪元素。

    p::before {content:"第一章:";}
    p::after {content:"Hot!";}
    p::first-line {background:red;}
    p::first-letter {font-size:30px;}
    复制代码
  • 伪类:伪类就像为元素增加了一个类,用于表达元素的特殊状态或性质。这些类都是浏览器可识别的一些“特殊效果”,当元素处于这些状态时就会被css选择器捕获。

    a:hover {color: #FF00FF}
    p:first-child {color: red}
    复制代码

总结来说就是,伪元素是元素通过css前后额外插入的元素或样式,并非来自文档源码,而伪类是具有特殊状态或性质的元素,是已存在元素

对requestAnimationFrame的理解

从字面意思来看,window.requestAnimationFrame是用于请求动画帧的api,而实际上,这个api和setTimeout定时器的功能基本一致,都能在指定时间间隔后触发回调函数。而requestAnimationFreame的作用更为明确:专门用于处理动画的。

比如我们利用定时器循环处理元素样式,来达到动画的效果,而每执行一次的动作就可以看作一次动画帧,当定时器循环执行连贯起来,也就达到了动画的效果。既然setTimeoutsetInterval定时器都能实现js动画,为何又要特别使用requestAnimationFrame呢?

我们知道,setTimeoutsetInterval除了指定回调函数,还需要指定执行间隔,而执行间隔关系到动画的刷新频率,首先不好控制,如果频率太快电脑更新不上还容易造成卡顿感,其次,这两个定时器是异步执行的,某些场景下并不好控制。基于这些问题,HTM5t提出的requestAnimationFrame专门对动画处理进行了优化

  • 首先,这个api不需要指定调用间隔时间。这个时间会自动适应显示屏的刷新频率,比如显示屏刷新频率为60HZ,也就是60次每秒,换算成一次的执行间隔就是1/60秒,即1 / 60 * 1000 s = 16.67ms
  • 非异步调用,回调是在主线程中执行的。意味着主线程如果很繁忙时不推荐频繁进行动画重绘。
  • 动画会触发页面重绘,而当浏览器标签页处于后台或处于隐藏的iframe中时,该api回调是暂停执行的,是对CPU友好的。

api使用上基本与setTimeout一致:

const requestId = requestAnimationFrame(call); // 调用一次动画帧
cancelAnimationFrame(requestId); // 取消动画帧的调用
复制代码

如果浏览器不支持该api,我们还可以使用setTimeout来模拟:

(function() {
    if(!window.requestAnimationFrame) {
        window.requestAnimationFrame = (call) => {
            return setTimeout(function() {
               call(performance.now()); 
            }, 16.67);
        };
    }
})()
复制代码

下面是一个简单的动画示例:

<div id="anim" style="position: absolute;">
    悬浮暂停动画
</div>
复制代码
const ele = document.getElementById('anim');

let startTime = undefined; // 其实执行时间
let startLeft = 0; // 起始left值

// 重绘逻辑
function render(timestamp) {
    if(timestamp === undefined) {
        timestamp = performance.now(); // 当前执行时间
    }
    if(startTime === undefined) {
        startTime = timestamp - (startLeft + 500) * 10; // 恢复到暂停时的时间
    }
    const left = (timestamp - startTime) / 10 % 500; // 0 - 500 间波动
    ele.style.left = left + 'px';
}

let animRequestId = undefined
// 暂停动画
function stopAnimation() {
    if(animRequestId === undefined) {
        return;
    }
    cancelAnimationFrame(animRequestId);
    animRequestId = undefined;
    startTime = undefined;
    startLeft = parseFloat(ele.style.left || '0');
}
// 开启动画
function startAnimation() {
    if(animRequestId === undefined) {
        (function animaloop() {
            render();
            animRequestId = requestAnimationFrame(animaloop);
        })();
    }
}
ele.addEventListener('mouseover', stopAnimation);
ele.addEventListener('mouseleave', startAnimation);
window.onload = function() {
    startAnimation();
};
复制代码

参考:

isNaN 和 Number.isNaN 函数的区别?

由于NaN是非自反数值,使用 ===== 与其他任何值比较都将不相等,所以判断数值是否为NaN必须通过isNaNNumber.isNaN函数。

isNaN函数用于检查其参数是否是非数字值。检测之前会先转换为数字再进行检测。而Number.isNaN直接进行比较而不会进行类型转换。

==操作符的类型转换原则

=====是不同的,==是非全等,再进行值比较时会进行自动类型转换,即转换为相同类型的值再进行比较

数据类型分为基本数据类型和引用类型,而引用类型之间的比较是直接比较物理引用地址的,无需转换。基本数据类型有7种:undefined、null、boolean、number、string、Symbol和BigInt。其中,可以进行数据类型转换的也就string、boolean、number这三种。

转换流程如下:

  1. 类型相同,无需转换,直接比较

  2. nullundefined的比较,返回true

  3. string | boolean和其他基本数据类型的比较:string | boolean转数字后再比较

  4. object和基本数据类型的比较:对象执行Symbol.toPrimitive抽象操作后再进行比较(Symbol.toPrimitive 是一个内置的 Symbol 值,它是作为对象的函数值属性存在的,当一个对象转换为对应的原始值时,会调用此函数)

    // 一个没有提供 Symbol.toPrimitive 属性的对象,参与运算时的输出结果
    var obj1 = {};
    console.log(+obj1);     // NaN
    console.log(`${obj1}`); // "[object Object]"
    console.log(obj1 + ""); // "[object Object]"
    
    // 接下面声明一个对象,手动赋予了 Symbol.toPrimitive 属性,再来查看输出结果
    var obj2 = {
      [Symbol.toPrimitive](hint) {
        if (hint == "number") {
          return 10;
        }
        if (hint == "string") {
          return "hello";
        }
        return true;
      }
    };
    console.log(+obj2);     // 10      -- hint 参数值是 "number"
    console.log(`${obj2}`); // "hello" -- hint 参数值是 "string"
    console.log(obj2 + ""); // "true"  -- hint 参数值是 "default"
    复制代码
  5. 返回false

我们发现,排除了特殊情况后只有string | booleannumber和对象转基本类型两种情况。

除了==,还有+-等类型操作时也可能发生类型转换,类型转换是弱类型语言的一个特点,但也是一个缺点,因为不够严谨,可能转换并不是我们需要的,因此,再进行数据比较和运算时,主动转换为相同类型是更推荐的做法。

Vue实例属性computed vs methods vs watch

computed是计算属性,语法就像getter/setter那样,同时也支持函数作为参数:

{ [key: string]: Function | { get: Function, set: Function } }

计算属性的初衷在于“计算”两个字上,我们希望得到一个可以通过某种公式或逻辑计算出来的值,这个时候就可以使用计算属性。

计算属性有个特点:缓存。计算属性是基于响应式依赖进行缓存的,这意为这当依赖发生变化会自动更新计算属性值缓存,未变化则不会更新,而每次取值则直接读取值缓存即可。同样的,由于需要编译响应式更新计算属性的逻辑,计算属性中的异步代码就会失效,也就是说在计算属性中使用异步代码是无意义的。

watch是Vue实例的属性侦听器,当侦听的属性变化时,可以触发侦听事件。利用这个属性侦听器,我们也可以在某些属性变化后更新某些动态属性,这其实是和计算属性的处理逻辑是一样的,依赖属性变化时更新缓存,而我们通过watch不过是改成了手动更新。

因此,watch更应该专注属性变化的处理逻辑,而不是维护某个属性值,这一任务更应该交由计算属性来完成。

methods顾名思义,存储了实例复用的一些函数,函数当然也可以用来计算属性,但是函数就失去了缓存的功能,每次更新我们都需要重新计算,这对复杂运算来说是不换算的开销。但methods的灵活和强大是计算属性无法比拟的,比如异步操作的数据,计算属性就无能为力了,只是相对来说,在获得计算依赖性质的属性这事上,还是交个computed更好。

总结来说,对于需要计算依赖性的属性,大多数情况使用computed即可,对于异步这种数据变化,无法主动更新的属性,我们只能改用watch或者methods,但正如字面意义那样,watch更应该专注处理侦听逻辑。

参考:

Vue中的slot是什么?有什么作用?原理是什么?

slot是Vue中的内置插槽组件,是Vue的内容分发机制。Vue 实现了一套内容分发的 API,这套 API 的设计灵感源自 Web Components 规范草案,将 <slot> 元素作为承载分发内容的出口。

比如组件navigation-link使用时:

<navigation-link url="/profile">
  Your Profile
</navigation-link>
复制代码

我们需要接收Your Profile,就需要用到插槽。然后你可以在 <navigation-link> 的模板中这样写:

<a
  v-bind:href="url"
  class="nav-link"
>
  <slot></slot>
</a>
复制代码

那么slot元素的位置就会被组件实例的内容值替换。

插槽有三种类型:

  • 匿名插槽:是指slot没有指定name属性时的插槽,一个组件内只能有一个匿名插槽。

  • 具名插槽:当组件内有多个插槽时,就需要为插槽命名,需要设置name属性值,因次叫具名插槽。(实际上,匿名插槽的名称为default)

    有时我们需要多个插槽。例如对于一个带有如下模板的 <base-layout> 组件:

    <div class="container">
      <header>
        <!-- 我们希望把页头放这里 -->
      </header>
      <main>
        <!-- 我们希望把主要内容放这里 -->
      </main>
      <footer>
        <!-- 我们希望把页脚放这里 -->
      </footer>
    </div>
    复制代码

    这样,我们就需要三个插槽,为了区分,就需要带上名称,也就是name属性:

    <div class="container">
      <header>
        <slot name="header"></slot>
      </header>
      <main>
        <slot></slot>
      </main>
      <footer>
        <slot name="footer"></slot>
      </footer>
    </div>
    复制代码

    在向具名插槽提供内容的时候,我们可以在一个 <template> 元素上使用 v-slot 指令,并以 v-slot 的参数的形式提供其名称:

    <base-layout>
      <template v-slot:header>
        <h1>Here might be a page title</h1>
      </template>
    
      <p>A paragraph for the main content.</p>
      <p>And another one.</p>
    
      <template v-slot:footer>
        <p>Here's some contact info</p>
      </template>
    </base-layout>
    复制代码

    现在 <template> 元素中的所有内容都将会被传入相应的插槽。任何没有被包裹在带有 v-slot<template> 中的内容都会被视为默认插槽的内容。

    最终的渲染结果:

    <div class="container">
      <header>
        <h1>Here might be a page title</h1>
      </header>
      <main>
        <p>A paragraph for the main content.</p>
        <p>And another one.</p>
      </main>
      <footer>
        <p>Here's some contact info</p>
      </footer>
    </div>
    复制代码
  • 作用域插槽

    有一个编译规则是:父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。

    slot是组件模板中,也就是父作用域,我们知道,父作用域不经过特殊处理无法访问子作用域,但有时候在插槽模板中使用子作用域的数据是很有用的。就像现父作用域和子作用域通信那样,我们可以使用prop属性来传递数据。在组件实例中可以使用v-slot:slot-name="slotProps"来传递属性值,其中slotProps就是子作用域传递给父作用域的数据对象。然后在组件模板中使用v-bind:propName="propObj"来获取数据。

    比如:父作用域组件模板可以这样写:

    <span>
      <slot v-bind:user="user">
        {{ user.lastName }}
      </slot>
    </span>
    复制代码

    使用时,可以这样绑定:

    <current-user>
      <template v-slot:default="slotProps">
        {{ slotProps.user.firstName }}
      </template>
    </current-user>
    复制代码

    在这个例子中,我们选择将包含所有插槽 prop 的对象命名为 slotProps,但你也可以使用任意你喜欢的名字。

实现原理:当子组件vm实例化时,获取到父组件传入的slot标签的内容,存放在vm.$slot中,默认插槽为vm.$slot.default,具名插槽为vm.$slot.xxx,xxx 为插槽名,当组件执行渲染函数时候,遇到slot标签,使用$slot中的内容进行替换,此时可以为插槽传递数据,若存在数据,则可称该插槽为作用域插槽。

参考:

手写Object.create

**Object.create()**方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__

语法:Object.create(proto,[propertiesObject])

  • proto:新建对象的(隐式)原型对象
  • propertiesObject:可选,该对象的属性类型参照Object.defineProperties()

简单来说,就是通过proto参数来创建新对象,并且这个新对象的原型指向这个参数。我们知道__proto__的赋值以及新对象的创建可以通过new关键字来实现 。

if(typeof Object.create !== 'function') {
    Object.create = function(proto, propertiesObject) {
        if(!proto || (typeof proto !== 'object' && typeof proto !== 'function')) {
            throw new Error('Object prototype may only be an Object: ' + proto);
        }
        // 第二个参数可以用 Object.defineproperty 来实现,
        // 但一般 Object.Create 不支持,说明 Object.defineproperty 也不支持 
        if(propertiesObject !== undefined) {
            throw new Error("This browser's implementation of Object.create is a shim and doesn't support a second argument.");
        }
        function F() {};
        F.prototype = proto;
        return new F();
    }
}
复制代码

扩展:对象的__proto__我们一般称之为对象的隐式原型,指向父类的显示原型prototype,所以我们还能利用Object.create来实现类的继承。

手写新函数除了理解其运作原理,还有一个很有用的作用,就是polyfillpolyfill意为填充物、补丁,在编程中一般用于低版本”打补丁“,“兜底”。比如Object.create是ES6的语法,在低版本浏览器就无法运行,因此手写版的Object.create就是这个函数的polyfill。不过这些工作基本都有现成的库如babel来做了。

手写instanceof方法

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。即判断实例对象是否为某个函数(类)的子类实例。

关键点在于遍历实例对象的原型链,然后判断构造函数是否与原型链上的原型相等。

function my_instanceOf(A, B) {
    if(!A || !B) {
        return false;
    }
    const B_proto = B.prototype; // 获取B的构造函数原型(通常说的显示原型)
    let prototype_link_next = Object.getPrototypeOf(A); // 返回对象A的原型(通常说的隐式原型)(同A.__prototype__,但非标准,不推荐使用)
    // 原型链简单来说就是:子类的隐式原型 = 父类的显示原型 => 子类的原型 = 父类的构造函数原型
    // 当B的构造函数原型出现在A的原型链中时,说明A为B的子类,也就是B的实例
    while(prototype_link_next) {
        if(B_proto === prototype_link_next) {
            return true;
        }
        prototype_link_next = Object.getPrototypeOf(prototype_link_next);
    }
    return false;
}
复制代码

输出结果

代码段:

const promise = new Promise((resolve, reject) => {
  console.log(1);
  console.log(2);
});
promise.then(() => {
  console.log(3);
});
console.log(4);
复制代码

本题考查的是Promise的基本执行流程:Promise对象用于表示一个异步操作的最终完成 (或失败)及其结果值。有三个状态:

  • padding(待定):初始状态,宏任务执行后的异步执行等待阶段。
  • fulfilled(已兑现):异步执行成功。后续可执行微任务。
  • rejected(已拒绝):异步执行失败。后续可执行微任务。

案例代码,定义了一个Promise对象,对象内部的代码是宏任务,是同步执行的,但是没有修改Promise的执行回调,也就是状态未改变,那么微任务也就不会执行,所以最后的输出结果为:

1
2
4
复制代码

代码段:

const promise1 = new Promise((resolve, reject) => {
  console.log('promise1')
  resolve('resolve1')
})
const promise2 = promise1.then(res => {
  console.log(res)
})
console.log('1', promise1);
console.log('2', promise2);
复制代码

本题考察内容除了Promise的执行流程,还有Promise对象的打印细节:Promise对象打印时,会打印状态加参数。

  • 等待状态:Promise { }
  • 成功状态:Promise { : 参数}(注意node控制台不打印状态)
  • 拒绝状态:Promise { : 参数}

所以本题的打印结果为:

promise1
1 Promise {<fulfilled>: 'resolve1'}
2 Promise {<padding>}
resolve1
复制代码

字符串相加

给定两个字符串形式的非负整数 num1 和num2 ,计算它们的和。
输入:
"9333852702227987"
"85731737104263"
输出:
"9419584439332250"
复制代码

注意本题的特点是字符串可能特别长,会超过数字最大存储范围,因此,本题不能将字符串转为数字再相加,这样会丢失精度。本题可以通过模拟数字相加的方法,逐一计算出最后的答案。

/**
 * 字符串加法运算
 * @param {string} str1 
 * @param {string} str2 
 */
function addStrings(str1, str2) {
    const num1 = str1.split('');
    const num2 = str2.split('');
    let i = num1.length - 1;
    let j = num2.length - 1;
    let sumStr = '';
    let carry = 0;
    while(i >= 0 || j >= 0) {
        let a = i < 0 ? 0 : +num1[i--];
        let b = j < 0 ? 0 : +num2[j--];
        let sum = a + b + carry;
        carry = parseInt(sum / 10);
        sumStr = (sum % 10) + sumStr;
    }
    return carry ? carry + sumStr : sumStr;
}
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享