前端干货预警🚨马上金三银四,精选10道最新面试题带你起飞🛫️

4,917 阅读34分钟

精选10道面试题

1.Vue v-model 是如何实现的,语法糖实际是什么

公司:脉脉

分类:Vue

答案&解析

一、语法糖

指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。糖在不改变其所在位置的语法结构的前提下,实现了运行时的等价。可以简单理解为,加糖后的代码编译后跟加糖前一样,代码更简洁流畅,代码更语义自然.

二、实现原理

1.作用在普通表单元素上

动态绑定了 input 的 value 指向了 messgae 变量,并且在触发 input 事件的时候去动态把 message 设置为目标值

<input v-model="sth" />
//  等同于
<input 
    v-bind:value="message" 
    v-on:input="message=$event.target.value"
>
//$event 指代当前触发的事件对象;
//$event.target 指代当前触发的事件对象的dom;
//$event.target.value 就是当前dom的value值;
//在@input方法中,value => sth;
//在:value中,sth => value;

2.作用在组件上

在自定义组件中,v-model 默认会利用名为 value 的 prop 和名为 input 的事件

本质是一个父子组件通信的语法糖,通过prop和$.emit实现

因此父组件v-model语法糖本质上可以修改为 '<child :value="message" @input="function(e){message = e}"></child>'

在组件的实现中,我们是可以通过 v-model属性 来配置子组件接收的prop名称,以及派发的事件名称。

例子

// 父组件
<aa-input v-model="aa"></aa-input>
// 等价于
<aa-input v-bind:value="aa" v-on:input="aa=$event.target.value"></aa-input>

// 子组件:
<input v-bind:value="aa" v-on:input="onmessage"></aa-input>

props:{value:aa,}
methods:{
    onmessage(e){
        $emit('input',e.target.value)
    }
}

默认情况下,一个组件上的 v-model 会把 value 用作 prop 且把 input 用作 event

但是一些输入类型比如单选框和复选框按钮可能想使用 value prop 来达到不同的目的。使用 model 选项可以回避这些情况产生的冲突。

js 监听input 输入框输入数据改变,用oninput ,数据改变以后就会立刻出发这个事件。

通过input事件把数据$emit 出去,在父组件接受。

父组件设置v-model的值为input$emit过来的值。


2.React 中 setState 后发生了什么?setState 为什么默认是异步?setState 什么时候是同步?

公司:微医

分类:React

答案&解析

一、React中setState后发生了什么

在代码中调用setState函数之后,React 会将传入的参数对象与组件当前的状态合并,然后触发所谓的调和过程(Reconciliation)。

经过调和过程,React 会以相对高效的方式根据新的状态构建 React 元素树并且着手重新渲染整个UI界面。

在 React 得到元素树之后,React 会自动计算出新的树与老树的节点差异,然后根据差异对界面进行最小化重渲染。

在差异计算算法中,React 能够相对精确地知道哪些位置发生了改变以及应该如何改变,这就保证了按需更新,而不是全部重新渲染。

二、setState 为什么默认是异步

假如所有setState是同步的,意味着每执行一次setState时(有可能一个同步代码中,多次setState),都重新vnode diff + dom修改,这对性能来说是极为不好的。如果是异步,则可以把一个同步代码中的多个setState合并成一次组件更新。

三、setState 什么时候是同步

在setTimeout或者原生事件中,setState是同步的。


3.多个 tab 只对应一个内容框,点击每个 tab 都会请求接口并渲染到内容框,怎么确保频繁点击 tab 但能够确保数据正常显示?

公司:爱范儿

分类:JavaScript

答案&解析

一、分析

因为每个请求处理时长不一致,可能会导致先发送的请求后响应,即请求响应顺序和请求发送顺序不一致,从而导致数据显示不正确。

即可以理解为连续触发多个请求,如何保证请求响应顺序和请求发送顺序一致。对于问题所在场景,用户只关心最后数据是否显示正确,即可以简化为:连续触发多个请求,如何保证最后响应的结果是最后发送的请求(不关注之前的请求是否发送或者响应成功)

类似场景:input输入框即时搜索,表格快速切换页码

二、解决方案

防抖(过滤掉一些非必要的请求) + 取消上次未完成的请求(保证最后一次请求的响应顺序)

取消请求方法:

  • XMLHttpRequest 使用 abort api 取消请求
  • axios 使用 cancel token 取消请求

伪代码(以 setTimeout 模拟请求,clearTimeout 取消请求)

/**
 * 函数防抖,一定时间内连续触发事件只执行一次
 * @param {*} func 需要防抖的函数
 * @param {*} delay 防抖延迟
 * @param {*} immediate 是否立即执行,为true表示连续触发时立即执行,即执行第一次,为false表示连续触发后delay ms后执行一次
 */
let debounce = function(func, delay = 100, immediate = false) {
  let timeoutId, last, context, args, result

  function later() {
    const interval = Date.now() - last
    if (interval < delay && interval >= 0) {
      timeoutId = setTimeout(later, delay - interval)
    } else {
      timeoutId = null
      if (!immediate) {
        result = func.apply(context, args)
        context = args = null
      }
    }
  }

  return function() {
    context = this
    args = arguments
    last = Date.now()

    if (immediate && !timeoutId) {
      result = func.apply(context, args)
      context = args = null // 解除引用
    }
    
    if (!timeoutId) {
      timeoutId = setTimeout(later, delay)
    }

    return result
  }
}


let flag = false   // 标志位,表示当前是否正在请求数据
let xhr = null

let request = (i) => {
    if (flag) {
        clearTimeout(xhr)
        console.log(`取消第${i - 1}次请求`)
    }
    flag = true
    console.log(`开始第${i}次请求`)
    xhr = setTimeout(() => {
        console.log(`请求${i}响应成功`)
        flag = false
    }, Math.random() * 200)
}

let fetchData = debounce(request, 50)  // 防抖

// 模拟连续触发的请求
let count = 1 
let getData = () => {
  setTimeout(() => {
    fetchData(count)
    count++
    if (count < 11) {
        getData()
    }
  }, Math.random() * 200)
}
getData()

/* 某次测试输出:
    开始第2次请求
    请求2响应成功
    开始第3次请求
    取消第3次请求
    开始第4次请求
    请求4响应成功
    开始第5次请求
    请求5响应成功
    开始第8次请求
    取消第8次请求
    开始第9次请求
    请求9响应成功
    开始第10次请求
    请求10响应成功
*/

前方高能预警🚨 🚨 🚨

因文章篇幅过长,影响阅读体验,接下来的7道题目答案将被折叠,点击查看解析,即可查看全部答案,选择你感兴趣的题查看答案吧~


4.对虚拟 DOM 的理解?虚拟 DOM 主要做了什么?虚拟 DOM 本身是什么?

公司:有赞、微医、58

分类:React、Vue

答案&解析

查看解析

一、什么是虚拟Dom

从本质上来说,Virtual Dom是一个JavaScript对象,通过对象的方式来表示DOM结构。将页面的状态抽象为JS对象的形式,配合不同的渲染工具,使跨平台渲染成为可能。通过事务处理机制,将多次DOM修改的结果一次性的更新到页面上,从而有效的减少页面渲染的次数,减少修改DOM的重绘重排次数,提高渲染性能

虚拟dom是对DOM的抽象,这个对象是更加轻量级的对DOM的描述。它设计的最初目的,就是更好的跨平台,比如Node.js就没有DOM,如果想实现SSR,那么一个方式就是借助虚拟dom, 因为虚拟dom本身是js对象。

在代码渲染到页面之前,vue或者react会把代码转换成一个对象(虚拟DOM)。以对象的形式来描述真实dom结构,最终渲染到页面。在每次数据发生变化前,虚拟dom都会缓存一份,变化之时,现在的虚拟dom会与缓存的虚拟dom进行比较。

在vue或者react内部封装了diff算法,通过这个算法来进行比较,渲染时修改改变的变化,原先没有发生改变的通过原先的数据进行渲染。

另外现代前端框架的一个基本要求就是无须手动操作DOM,一方面是因为手动操作DOM无法保证程序性能,多人协作的项目中如果review不严格,可能会有开发者写出性能较低的代码,另一方面更重要的是省略手动DOM操作可以大大提高开发效率。

二、为什么要用 Virtual DOM

1.保证性能下限,在不进行手动优化的情况下,提供过得去的性能

看一下页面渲染的一个流程:

  • 解析HTNL ☞ 生成DOM🌲 ☞ 生成 CSSOM ☞ Layout ☞ Paint ☞ Compiler

下面对比一下修改DOM时真实DOM操作和Virtual DOM的过程,来看一下它们重排重绘的性能消耗:

  • 真实DOM: 生成HTML字符串 + 重建所有的DOM元素
  • Virtual DOM: 生成vNode + DOMDiff + 必要的dom更新

Virtual DOM的更新DOM的准备工作耗费更多的时间,也就是JS层面,相比于更多的DOM操作它的消费是极其便宜的。尤雨溪在社区论坛中说道: 框架给你的保证是,你不需要手动优化的情况下,我依然可以给你提供过得去的性能。

2.跨平台

Virtual DOM本质上是JavaScript的对象,它可以很方便的跨平台操作,比如服务端渲染、uniapp等。

三、Virtual DOM真的比真实DOM性能好吗

  1. 首次渲染大量DOM时,由于多了一层虚拟DOM的计算,会比innerHTML插入慢。
  2. 正如它能保证性能下限,在真实DOM操作的时候进行针对性的优化时,还是更快的。

5.Webpack 为什么慢,如何进行优化

分类:工程化

答案&解析

查看解析

一、webpack 为什么慢

webpack是所谓的模块捆绑器,内部有循环引用来分析模块间之间的依赖,把文件解析成AST,通过一系类不同loader的加工,最后全部打包到一个js文件里。

webpack4以前在打包速度上没有做过多的优化手段,编译慢的大部分时间是花费在不同loader编译过程,webpack4以后,吸收借鉴了很多优秀工具的思路,

如支持0配置,多线程等功能,速度也大幅提升,但依然有一些优化手段。如合理的代码拆分,公共代码的提取,css资源的抽离

二、优化 Webpack 的构建速度

  • 使用高版本的 Webpack (使用webpack4)
  • 多线程/多实例构建:HappyPack(不维护了)、thread-loader
  • 缩小打包作用域:
    • exclude/include (确定 loader 规则范围)
    • resolve.modules 指明第三方模块的绝对路径 (减少不必要的查找)
    • resolve.extensions 尽可能减少后缀尝试的可能性
    • noParse 对完全不需要解析的库进行忽略 (不去解析但仍会打包到 bundle 中,注意被忽略掉的文件里不应该包含 import、require、define 等模块化语句)
    • IgnorePlugin (完全排除模块)
    • 合理使用alias
  • 充分利用缓存提升二次构建速度:
    • babel-loader 开启缓存
    • terser-webpack-plugin 开启缓存
    • 使用 cache-loader 或者 hard-source-webpack-plugin 注意:thread-loader 和 cache-loader 兩個要一起使用的話,請先放 cache-loader 接著是 thread-loader 最後才是 heavy-loader
  • DLL
    • 使用 DllPlugin 进行分包,使用 DllReferencePlugin(索引链接) 对 manifest.json 引用,让一些基本不会改动的代码先打包成静态资源,避免反复编译浪费时间。

三、使用Webpack4带来的优化

  • V8带来的优化(for of替代forEach、Map和Set替代Object、includes替代indexOf)
  • 默认使用更快的md4 hash算法
  • webpack AST可以直接从loader传递给AST,减少解析时间
  • 使用字符串方法替代正则表达式

来看下具体使用

1.noParse

  • 不去解析某个库内部的依赖关系
  • 比如jquery 这个库是独立的, 则不去解析这个库内部依赖的其他的东西
  • 在独立库的时候可以使用
module.exports = {
  module: {
    noParse: /jquery/,
    rules:[]
  }
}

2.IgnorePlugin

  • 忽略掉某些内容 不去解析依赖库内部引用的某些内容
  • 从moment中引用 ./local 则忽略掉
  • 如果要用local的话 则必须在项目中必须手动引入 import 'moment/locale/zh-cn'
module.exports = {
  plugins: [
    new Webpack.IgnorePlugin(/\.\/local/, /moment/),
  ]
}

3.dillPlugin

  • 不会多次打包, 优化打包时间
  • 先把依赖的不变的库打包
  • 生成 manifest.json文件
  • 然后在webpack.config中引入
  • webpack.DllPluginWebpack.DllReferencePlugin

4.happypack -> thread-loader

  • 大项目的时候开启多线程打包
  • 影响前端发布速度的有两个方面,一个是 构建 ,一个就是 压缩 ,把这两个东西优化起来,可以减少很多发布的时间。

5.thread-loader

thread-loader 会将您的 loader 放置在一个 worker 池里面运行,以达到多线程构建。

把这个 loader 放置在其他 loader 之前,放置在这个 loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行。

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve("src"),
        use: [
          "thread-loader",
          // 你的高开销的loader放置在此 (e.g babel-loader)
        ]
      }
    ]
  }
}

每个 worker 都是一个单独的有 600ms 限制的 node.js 进程。同时跨进程的数据交换也会被限制。请在高开销的loader中使用,否则效果不佳

6.压缩加速——开启多线程压缩

不推荐使用 webpack-paralle-uglify-plugin,项目基本处于没人维护的阶段,issue 没人处理,pr没人合并。

Webpack 4.0以前:uglifyjs-webpack-plugin,parallel参数

module.exports = {
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        parallel: true,
      }),
    ],
  },};

推荐使用 terser-webpack-plugin

module.exports = {
  optimization: {
    minimizer: [new TerserPlugin(
      parallel: true   // 多线程
    )],
  },
};

6.客户端缓存有几种方式?浏览器出现 from disk、from memory 的策略是啥

分类:网络&安全

答案&解析

查看解析

一、客户端缓存

浏览器缓存策略:

浏览器每次发起请求时,先在本地缓存中查找结果以及缓存标识,根据缓存标识来判断是否使用本地缓存。如果缓存有效,则使用本地缓存;否则,则向服务器发起请求并携带缓存标识。根据是否需向服务器发起HTTP请求,将缓存过程划分为两个部分:强制缓存和协商缓存,强缓优先于协商缓存

HTTP缓存都是从第二次请求开始的

  • 第一次请求资源时,服务器返回资源,并在response header中回传资源的缓存策略;
  • 第二次请求时,浏览器判断这些请求参数,击中强缓存就直接200,否则就把请求参数加到request header头中传给服务器,看是否击中协商缓存,击中则返回304,否则服务器会返回新的资源。这是缓存运作的一个整体流程图:

img

1.强缓存

服务器通知浏览器一个缓存时间,在缓存时间内,下次请求,直接用缓存,不在时间内,执行比较缓存策略。

强缓存命中则直接读取浏览器本地的资源,在network中显示的是from memory或者from disk

控制强制缓存的字段有:Cache-Control(http1.1)和Expires(http1.0)

  • Cache-control是一个相对时间,用以表达自上次请求正确的资源之后的多少秒的时间段内缓存有效。
  • Expires是一个绝对时间。用以表达在这个时间点之前发起请求可以直接从浏览器中读取数据,而无需发起请求
  • Cache-Control的优先级比Expires的优先级高。前者的出现是为了解决Expires在浏览器时间被手动更改导致缓存判断错误的问题。
  • 如果同时存在则使用Cache-control。

1)强缓存-expires

该字段是服务器响应消息头字段,告诉浏览器在过期时间之前可以直接从浏览器缓存中存取数据。

Expires 是 HTTP 1.0 的字段,表示缓存到期时间,是一个绝对的时间 (当前时间+缓存时间)。在响应消息头中,设置这个字段之后,就可以告诉浏览器,在未过期之前不需要再次请求。

由于是绝对时间,用户可能会将客户端本地的时间进行修改,而导致浏览器判断缓存失效,重新请求该资源。此外,即使不考虑修改,时差或者误差等因素也可能造成客户端与服务端的时间不一致,致使缓存失效。

优势特点:

  • HTTP 1.0 产物,可以在HTTP 1.0和1.1中使用,简单易用。
  • 以时刻标识失效时间。

劣势问题:

  • 时间是由服务器发送的(UTC),如果服务器时间和客户端时间存在不一致,可能会出现问题。
  • 存在版本问题,到期之前的修改客户端是不可知的。

2)强缓存-cache-control

已知Expires的缺点之后,在HTTP/1.1中,增加了一个字段Cache-control,该字段表示资源缓存的最大有效时间,在该时间内,客户端不需要向服务器发送请求。

这两者的区别就是前者是绝对时间,而后者是相对时间。下面列举一些 Cache-control 字段常用的值:(完整的列表可以查看MDN)

  • max-age:即最大有效时间。
  • must-revalidate:如果超过了 max-age 的时间,浏览器必须向服务器发送请求,验证资源是否还有效。
  • no-cache:不使用强缓存,需要与服务器验证缓存是否新鲜。
  • no-store: 真正意义上的“不要缓存”。所有内容都不走缓存,包括强制和对比。
  • public:所有的内容都可以被缓存 (包括客户端和代理服务器, 如 CDN)
  • private:所有的内容只有客户端才可以缓存,代理服务器不能缓存。默认值。

Cache-control 的优先级高于 Expires,为了兼容 HTTP/1.0 和 HTTP/1.1,实际项目中两个字段都可以设置。

该字段可以在请求头或者响应头设置,可组合使用多种指令:

  • 可缓存性
    • public:default,浏览器和缓存服务器都可以缓存页面信息
    • private:代理服务器不可缓存,只能被单个用户缓存
    • no-cache:浏览器器和服务器都不应该缓存页面信息,但仍可缓存,只是在缓存前需要向服务器确认资源是否被更改。可配合private, 过期时间设置为过去时间。
    • only-if-cache:客户端只接受已缓存的响应
  • 到期
    • max-age=<seconds>:缓存存储的最大周期,超过这个周期被认为过期。
    • s-maxage=<seconds>:设置共享缓存,比如can。会覆盖max-age和expires。
    • max-stale[=<seconds>]:客户端愿意接收一个已经过期的资源
    • min-fresh=<seconds>:客户端希望在指定的时间内获取最新的响应
    • stale-while-revalidate=<seconds>:客户端愿意接收陈旧的响应,并且在后台一部检查新的响应。时间代表客户端愿意接收陈旧响应 的时间长度。
    • stale-if-error=<seconds>:如新的检测失败,客户端则愿意接收陈旧的响应,时间代表等待时间。
  • 重新验证和重新加载
    • must-revalidate:如页面过期,则去服务器进行获取。
    • proxy-revalidate:用于共享缓存。
    • immutable:响应正文不随时间改变。
  • 其他
    • no-store:绝对禁止缓存
    • no-transform:不得对资源进行转换和转变。例如,不得对图像格式进行转换。

优势特点:

  • HTTP 1.1 产物,以时间间隔标识失效时间,解决了Expires服务器和客户端相对时间的问题。
  • 比Expires多了很多选项设置。

劣势问题:

  • 存在版本问题,到期之前的修改客户端是不可知的。

2.协商缓存

让客户端与服务器之间能实现缓存文件是否更新的验证、提升缓存的复用率,将缓存信息中的Etag和Last-Modified通过请求发送给服务器,由服务器校验,返回304状态码时,浏览器直接使用缓存。

  • 协商缓存的状态码由服务器决策返回200或者304
  • 当浏览器的强缓存失效的时候或者请求头中设置了不走强缓存,并且在请求头中设置了If-Modified-Since 或者 If-None-Match 的时候,会将这两个属性值到服务端去验证是否命中协商缓存,如果命中了协商缓存,会返回 304 状态,加载浏览器缓存,并且响应头会设置 Last-Modified 或者 ETag 属性。
  • 对比缓存在请求数上和没有缓存是一致的,但如果是 304 的话,返回的仅仅是一个状态码而已,并没有实际的文件内容,因此 在响应体体积上的节省是它的优化点。
  • 协商缓存有 2 组字段(不是两个),控制协商缓存的字段有:Last-Modified/If-Modified-since(http1.0)和Etag/If-None-match(http1.1)
  • Last-Modified/If-Modified-since表示的是服务器的资源最后一次修改的时间;Etag/If-None-match表示的是服务器资源的唯一标识,只要资源变化,Etag就会重新生成。
  • Etag/If-None-match的优先级比Last-Modified/If-Modified-since高。

1)协商缓存-协商缓存-Last-Modified/If-Modified-since

  1. 服务器通过 Last-Modified 字段告知客户端,资源最后一次被修改的时间,例如 Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT
  2. 浏览器将这个值和内容一起记录在缓存数据库中。
  3. 下一次请求相同资源时时,浏览器从自己的缓存中找出“不确定是否过期的”缓存。因此在请求头中将上次的 Last-Modified 的值写入到请求头的 If-Modified-Since 字段
  4. 服务器会将 If-Modified-Since 的值与 Last-Modified 字段进行对比。如果相等,则表示未修改,响应 304;反之,则表示修改了,响应 200 状态码,并返回数据。

优势特点:

  • 不存在版本问题,每次请求都会去服务器进行校验。服务器对比最后修改时间如果相同则返回304,不同返回200以及资源内容。

劣势问题:

  1. 只要资源修改,无论内容是否发生实质性的变化,都会将该资源返回客户端。例如周期性重写,这种情况下该资源包含的数据实际上一样的。
  2. 以时刻作为标识,无法识别一秒内进行多次修改的情况。 如果资源更新的速度是秒以下单位,那么该缓存是不能被使用的,因为它的时间单位最低是秒。
  3. 某些服务器不能精确的得到文件的最后修改时间。
  4. 如果文件是通过服务器动态生成的,那么该方法的更新时间永远是生成的时间,尽管文件可能没有变化,所以起不到缓存的作用。

2)协商缓存-Etag/If-None-match

  • 为了解决上述问题,出现了一组新的字段 EtagIf-None-Match
  • Etag 存储的是文件的特殊标识(一般都是 hash 生成的),服务器存储着文件的 Etag 字段。之后的流程和 Last-Modified 一致,只是 Last-Modified 字段和它所表示的更新时间改变成了 Etag 字段和它所表示的文件 hash,把 If-Modified-Since 变成了 If-None-Match。服务器同样进行比较,命中返回 304, 不命中返回新资源和 200。
  • 浏览器在发起请求时,服务器返回在Response header中返回请求资源的唯一标识。在下一次请求时,会将上一次返回的Etag值赋值给If-No-Matched并添加在Request Header中。服务器将浏览器传来的if-no-matched跟自己的本地的资源的ETag做对比,如果匹配,则返回304通知浏览器读取本地缓存,否则返回200和更新后的资源。
  • Etag 的优先级高于 Last-Modified

优势特点:

  • 可以更加精确的判断资源是否被修改,可以识别一秒内多次修改的情况。
  • 不存在版本问题,每次请求都回去服务器进行校验。

劣势问题:

  • 计算ETag值需要性能损耗。
  • 分布式服务器存储的情况下,计算ETag的算法如果不一样,会导致浏览器从一台服务器上获得页面内容后到另外一台服务器上进行验证时现ETag不匹配的情况。

二、浏览器出现 from disk、from memory 的策略

强缓存:服务器通知浏览器一个缓存时间,在缓存时间内,下次请求,直接用缓存,不在时间内,执行其他缓存策略

  1. 浏览器发现缓存无数据,于是发送请求,向服务器获取资源
  2. 服务器响应请求,返回资源,同时标记资源的有效期Cache-Contrl: max-age=3000
  3. 浏览器缓存资源,等待下次重用

7.商城的列表页跳转到商品的详情页,详情页数据接口很慢,前端可以怎么优化用户体验?

公司:爱范儿

分类:JavaScript

答案&解析

查看解析

一、优化简要版

1)懒加载:获取首屏数据,后边的数据进行滑动加载请求

  1. 首先,不要将图片地址放到src属性中,而是放到其它属性(data-original)中。
  2. 页面加载完成后,根据scrollTop判断图片是否在用户的视野内,如果在,则将data-original属性中的值取出存放到src属性中。
  3. 在滚动事件中重复判断图片是否进入视野,如果进入,则将data-original属性中的值取出存放到src属性中

2)利用骨架屏提升用户体验

3)PreloadJS预加载

使用PreloadJS库,PreloadJS提供了一种预加载内容的一致方式,以便在HTML应用程序中使用。预加载可以使用HTML标签以及XHR来完成。默认情况下,PreloadJS会尝试使用XHR加载内容,因为它提供了对进度和完成事件的更好支持,但是由于跨域问题,使用基于标记的加载可能更好。

4)除了添加前端loading和超时404页面外,接口部分可以添加接口缓存和接口的预加载

  1. 使用workbox对数据进行缓存 缓存优先
  2. 使用orm对本地离线数据进行缓存 优先请求本地。
  3. 采用预加载 再进入到详情页阶段使用quicklink预加载详情页
  4. 使用nodejs作为中间层将详情页数据缓存至redis等 上面的方法,可以根据业务需求选择组合使用。

二、优化详细版

1.打开谷歌搜索为例

load和DOMContentLoad.png

  • 蓝色的分界线左边代表浏览器的 DOMContentLoaded,当初始 HTML 文档已完全加载和解析而无需等待样式表,图像和子帧完成加载时的标识;
  • 红色分界线代表 load, 当整个页面及所有依赖资源如样式表和图片都已完成加载时

所以我们可以大致分为在

  • TTFB 之前的优化
  • 浏览器上面渲染的优化

2.当网络过慢时在获取数据前的处理

首先先上一张经典到不能再经典的图

timing-overview.png

其中cnd在dns阶段, dom渲染在processing onload阶段

上图从 promot for unload 到 onload 的过程这么多步骤, 在用户体验来说, 一个页面从加载到展示超过 4 秒, 就会有一种非常直观的卡顿现象, 其中 load 对应的位置是 onLoad 事件结束后, 才开始构建 dom 树, 但是用户不一定是关心当前页面是否是完成了资源的下载; 往往是一个页面开始出现可见元素开始FCP 首次内容绘制或者是FC 首次绘制 此时用户视觉体验开始, 到TTI(可交互时间) , 可交互元素的出现, 意味着,用户交互体验开始, 这时候用户就可以愉快的浏览使用我们的页面啦;

所以这个问题的主要痛点是需要缩短到达 TTIFCP 的时间

但是这里已知进入我们详情页面时, 接口数据返回速度是很慢的, FCPFC , 以及加快到达 TTI , 就需要我们页面预处理了

3.页面数据缓存处理(缓存大法好)

第一次 进入详情页面, 可以使用骨架图进行模拟 FC 展示, 并且骨架图, 可使用背景图且行内样式的方式对首次进入详情页面进行展示, 对于请求过慢的详情接口使用 worker 进程, 对详情的接口请求丢到另外一个工作线程进行请求, 页面渲染其他已返回数据的元素; 当很慢的数据回来后, 需要对页面根据商品 id 签名为 key 进行 webp 或者是缩略图商品图的 cnd 路径 localStorage 的缓存, 商品 id 的签名由放在 cookie 并设置成 httpOnly

非第一次 进入详情页时, 前端可通过特定的接口请求回来对应的商品 id 签名的 cookieid, 读取 localStorage 的商品图片的缓存数据, 这样对于第一次骨架图的展示时间就可以缩短, 快速到达 TTI 与用户交互的时间, 再通过 worker 数据, 进行高清图片的切换

4.过期缓存数据的处理(后端控制为主, LRU 为辅)

对于缓存图片地址的处理, 虽说缓存图片是放在 localStorage 中, 不会用大小限制, 但是太多也是不好的, 这里使用 LRU 算法对图片以及其他 localStorage 进行清除处理, 对于超过 7 天的数据进行清理 localStorage 详情页的数据, 数据结构如下:

"读取后端的cookieID": {
  "path": "对应cdn图片的地址",
  "time": "缓存时间戳",
  "size": "大小"
}

5.数据缓存和过期缓存数据的处理主体流程

进入商品详情页,接口数据很慢时,对页面的优化

6.对于大请求量的请求(如详情页面中的猜你喜欢, 推荐商品等一些大数据量的静态资源)

  1. 由于这些不属于用户进入详情想第一时间获取的信息, 即不属于当前页面的目标主体, 所以这些可以使用 Intersection Observer API 进行主体元素的观察, 当当前主体元素被加载出来后, 在进行非主体元素的网络资源分配, 即网络空闲时再请求猜你喜欢, 推荐商品等资源, 处理请求优先级的问题
  2. 需要保证当前详情页的请求列表的请求数 不超过当前浏览器的请求一个 tcp 最大 http 请求数

7.当 worker 数据回来后, 出现 大量图片 替换对应元素的的 webp 或者缩略图出现的问题(静态资源过多)

这里有两种情景

  1. 移动端, 对于移动端, 一般不会出现大量图片, 一般一个商品详情页, 不会超过 100 张图片资源; 这时候, 选择懒加载方案; 根据 GitHub 现有的很多方案, 当前滑动到可被观察的元素后才加载当前可视区域的图片资源, 同样使用的是 Intersection Observer API ; 比如 vue 的一个库 vue-lazy , 这个库就是对 Intersection_Observer_API 进行封装, 对可视区域的 img 便签进行 data-src 和 src 属性替换

  2. 第二个情况, pc 端, 可能会出现大量的 img 标签, 可能多达 300~400 张, 这时候, 使用懒加载, 用户体验就不太好了; 比如说: 当用户在查看商品说明介绍时, 这些商品说明和介绍有可能只是一张张图片, 当用户很快速的滑动时, 页面还没懒加载完, 用户就有可能看不到想看的信息; 鉴于会出现这种情况, 这里给出一个方案就是, img 出现一张 load 一张; 实现如下:

// 这里针对非第一次进入详情页,
//当前localStorage已经有了当前详情页商品图片的缩略图
for(let i = 0; i < worker.img.length; i++) {
  // nodeList是对应img标签,
  // 注意, 这里对应的nodeList一定要使用内联style把位置大小设置好, 避免大量的重绘重排
  const img = nodeList[i]
  img.src = worker.img['path'];
  img.onerror = () => {
    // 将替换失败或者加载失败的图片降级到缩略图, 
    // 即缓存到localStorage的缩略图或者webp图
    // 兼容客户端处理webp失败的情况
  }
}

8.页面重绘重排处理

页面渲染流程

触发重排的操作主要是几何因素:

  1. 页面首次进入的渲染。
  2. 浏览器 resize
  3. 元素位置和尺寸发生改变的时候
  4. 可见元素的增删
  5. 内容发生改变
  6. 字体的 font 的改变。
  7. css 伪类激活。 .....

尽量减少上面这些产生重绘重排的操作

比如说:

这里产生很大的重绘重排主要发生在 worker 回来的数据替换页面中的图片 src 这一步

// 该节点为img标签的父节点
const imgParent = docucment.getElementById('imgParent'); 
// 克隆当前需要替换img标签的父元素下所有的标签
const newImgParent = imgParent.cloneNode(true); 
const imgParentParent = docucment.getElementById('imgParentParent');
for(let i = 0; i < newImgParent.children.length; i++) { 
// 批量获取完所有img标签后, 再进行重绘
  newImgParent.children[i].src = worker.img[i].path;
}
// 通过img父节点的父节点, 来替换整个img父节点
// 包括对应的所有子节点, 只进行一次重绘操作
imgParentParent.replaceChild(newImgParent, imgParent); 

9.css代码处理

注意被阻塞的css资源

众所周知, css的加载会阻塞浏览器其他资源的加载, 直至CSSOM CSS OBJECT MODEL 构建完成, 然后再挂在DOM树上, 浏览器依次使用渲染树来布局和绘制网页。

很多人都下意识的知道, 将css文件一律放到head标签中是比较好的, 但是为什么将css放在head标签是最后了呢?

我们用淘宝做例子

没有加载css的淘宝页面 比如这种没有css样式的页面称之为FOUC(内容样式短暂失效), 但是这种情况一般出现在ie系列以及前期的浏览器身上; 就是当cssom在domtree生成后, 依然还没完成加载出来, 先展示纯html代码的页面一会再出现正确的带css样式的页面;

减少不同页面的css代码加载

对于电商页面, 有些在头部的css代码有些是首页展示的有些是特定情况才展示的, 比如当我们需要减少一些css文件大小但是当前网站又需要多屏展示, 这时候, 很多人都会想到是媒体查询, 没错方向是对的, 但是怎样的媒体查询才对css文件保持足够的小呢, 可以使用link标签媒体查询,看下边的的例子:

<link href="base.css" rel="stylesheet">
<link href="other.css" rel="stylesheet" media="(min-width: 750px)">

第一个css资源表示所有页面都会加载, 第二个css资源, 宽度在750px才会加载, 默认media="all"

在一些需求写css媒体查询的网站, 不要在css代码里面写, 最好写两套css代码, 通过link媒体查询去动态加载, 这样就能很好的减轻网站加载css文件的压力

10.静态js代码处理

这种js代码, 是那些关于埋点, 本地日记, 以及动态修改css代码, 读取页面成型后的信息的一些js代码, 这种一律放在同域下的localStorage上面, 什么是同域下的localStorage

这里还是以天猫为例

11.容错处理

  1. 页面在获取到 worker 回来的数据后, 通过拷贝整个html片段, 再将worker的img路径在替换对应的 img 资源后再进行追加到对应的dom节点
  2. 缓存 css 文件和 js 文件到 localStorage 中, 若当前没有对应的 css 文件或者 js 文件, 或者被恶意修改过的 css 文件或者 js 文件(可使用签名进行判断), 删除再获取对应文件的更新

12.推荐方案理由

  1. 使用了 worker 线程请求详情数据, 不占用浏览器主线程; 进而减少主进程消耗在网络的时间
  2. 使用 localStorage 的缓存机制, 因为当 worker 回来的数据后, 读取 localStorage 是同步读取的, 基本不会有太大的等待时间, 并且读取 localStorage 时, 使用的是后端返回来的 cookieID 进行读取, 且本地的 cookID 是 httpOnly 避免了第三方获取到 cookieID 进行读取商品信息
  3. 使用 LRU 清除过多的缓存数据
  4. 首次进入页面时, 保证已知页面布局情况下的快速渲染以及配置骨架图, 加快到达 FCP 和 FP 的时间
  5. 就算 img 静态资源过大, 在第二次进入该页面的时候, 也可以做到低次数重绘重排, 加快到底 TTI 的时间

13.方案不足

  1. 在网络依然很慢的情况下, 首次进入详情页面, 如果长时间的骨架图和已知布局下, 用户的体验依然是不好的, 这里可以考虑 PWA 方案, 对最近一次成功请求的内容进行劫持, 并在无网情况下, 做出相应的提示和展示处理
  2. 需要 UI 那边提供三套静态 img 资源

8.说一下单点登录实现原理

公司:CVTE

分类:JavaScript

答案&解析

查看解析

一、什么是单点登录

单点登录SSO(Single Sign On),是一个多系统共存的环境下,用户在一处登录后,就不用在其他系统中登录,也就是用户的一次登录得到其他所有系统的信任

比如现有业务系统A、B、C以及SSO系统,第一次访问A系统时,发现没有登录,引导用户到SSO系统登录,根据用户的登录信息,生成唯一的一个凭据token,返回给用户。后期用户访问B、C系统的时候,携带上对应的凭证到SSO系统去校验,校验通过后,就可以单点登录;

单点登录在大型网站中使用的非常频繁,例如,阿里旗下有淘宝、天猫、支付宝等网站,其背后的成百上千的子系统,用户操作一次或者交易可能涉及到很多子系统,每个子系统都需要验证,所以提出,用户登录一次就可以访问相互信任的应用系统

单点登录有一个独立的认证中心,只有认证中心才能接受用户的用户名和密码等信息进行认证,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,当用户提供的用户名和密码通过认证中心认证后,认证中心会创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌即得到了授权,然后创建局部会话。

二、单点登录原理

单点登录有同域和跨域两种场景

1)同域

适用场景:都是企业自己的系统,所有系统都使用同一个一级域名通过不同的二级域名来区分。

举个例子:公司有一个一级域名为 zlt.com ,我们有三个系统分别是:门户系统(sso.zlt.com)、应用1(app1.zlt.com)和应用2(app2.zlt.com),需要实现系统之间的单点登录,实现架构如下

核心原理:

  1. 门户系统设置的cookie的domain为一级域名也是zlt.com,这样就可以共享门户的cookie给所有的使用该域名xxx.alt.com的系统
  2. 使用Spring Session等技术让所有系统共享Session
  3. 这样只要门户系统登录之后无论跳转应用1或者应用2,都能通过门户Cookie中的sessionId读取到Session中的登录信息实现单点登录

2)跨域

单点登录之间的系统域名不一样,例如第三方系统。由于域名不一样不能共享Cookie了,需要的一个独立的授权系统,即一个独立的认证中心(passport),子系统的登录均可以通过passport,子系统本身将不参与登录操作,当一个系统登录成功后,passprot将会颁发一个令牌给子系统,子系统可以拿着令牌去获取各自的保护资源,为了减少频繁认证,各个子系统在被passport授权以后,会建立一个局部会话,在一定时间内无需再次向passport发起认证

基本原理

  1. 用户第一次访问应用系统的时候,因为没有登录,会被引导到认证系统中进行登录;
  2. 根据用户提供的登录信息,认证系统进行身份校验,如果通过,返回给用户一个认证凭据-令牌
  3. 用户再次访问别的应用的时候,带上令牌作为认证凭证
  4. 应用系统接收到请求后会把令牌送到认证服务器进行校验,如果通过,用户就可以在不用登录的情况下访问其他信任的业务服务器。

登录流程

登录流程

  1. 用户访问系统1的受保护资源,系统1发现用户没有登录,跳转到sso认证中心,并将自己的地址作为参数
  2. sso认证中心发现用户未登录,将用户引导到登录页面
  3. 用户提交用户名、密码进行登录
  4. sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称之为全局会话,同时创建授权令牌
  5. sso 带着令牌跳转回最初的请求的地址(系统1)
  6. 系统1拿着令牌,去sso认证中心校验令牌是否有效
  7. sso认证中心校验令牌,返回有效,注册系统1(也就是返回一个cookie)
  8. 系统一使用该令牌创建与用户的会话,成为局部会话,返回受保护的资源
  9. 用户访问系统2受保护的资源
  10. 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
  11. sso认证中心发现用户已登录,跳转回系统2的地址,并且附上令牌
  12. 系统2拿到令牌,去sso中心验证令牌是否有效,返回有效,注册系统2
  13. 系统2使用该令牌创建与用户的局部会话,返回受保护资源
  14. 用户登录成功之后,会与sso认证中心以及各个子系统建立会话,用户与sso认证中心建立的会话称之为全局会话,用户与各个子系统建立的会话称之为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心

注销流程

注销流程

  1. 用户向系统提交注销操作
  2. 系统根据用户与系统1建立的会话,拿到令牌,向sso认证中心提交注销操作
  3. sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址
  4. sso认证中心向所有注册系统发起注销请求,各注册系统销毁局部会话
  5. sso认证中心引导用户到登录页面

9.说一下减少 dom 数量的办法?一次性给你大量的 dom 怎么优化?

公司:58

分类:Html

答案&解析

查看解析

一、减少DOM数量的方法

  1. 可以使用伪元素,阴影实现的内容尽量不使用DOM实现,如清除浮动、样式实现等;
  2. 按需加载,减少不必要的渲染;
  3. 结构合理,语义化标签;

二、大量DOM时的优化

当对Dom元素进行一系列操作时,对Dom进行访问和修改Dom引起的重绘和重排都比较消耗性能,所以关于操作Dom,应该从以下几点出发:

1.缓存Dom对象

首先不管在什么场景下。操作Dom一般首先会去访问Dom,尤其是像循环遍历这种时间复杂度可能会比较高的操作。那么可以在循环之前就将主节点,不必循环的Dom节点先获取到,那么在循环里就可以直接引用,而不必去重新查询。

let rootElem = document.querySelector('#app');
let childList = rootElem.child; // 假设全是dom节点
for(let i = 0;i<childList.len;j++){
    /**
    * 根据条件对应操作
    */
}

2.文档片段

利用document.createDocumentFragment()方法创建文档碎片节点,创建的是一个虚拟的节点对象。向这个节点添加dom节点,修改dom节点并不会影响到真实的dom结构。

我们可以利用这一点先将我们需要修改的dom一并修改完,保存至文档碎片中,然后用文档碎片一次性的替换真是的dom节点。与虚拟dom类似,同样达到了不频繁修改dom而导致的重排跟重绘的过程。

let fragment = document.createDocumentFragment();
const operationDomHandle = (fragment) =>{
    // 操作 
}
operationDomHandle(fragment);
// 然后最后再替换  
rootElem.replaceChild(fragment,oldDom);

这样就只会触发一次回流,效率会得到很大的提升。如果需要对元素进行复杂的操作(删减、添加子节点),那么我们应当先将元素从页面中移除,然后再对其进行操作,或者将其复制一个(cloneNode()),在内存中进行操作后再替换原来的节点。

var clone=old.cloneNode(true);
operationDomHandle(clone);
rootElem.replaceChild(clone,oldDom)

3.用innerHtml 代替高频的appendChild

4.最优的layout方案

批量读,一次性写。先对一个不在render tree上的节点进行操作,再把这个节点添加回render tree。这样只会触发一次DOM操作。 使用requestAnimationFrame(),把任何导致重绘的操作放入requestAnimationFrame

5.虚拟Dom

js模拟DOM树并对DOM树操作的一种技术。virtual DOM是一个纯js对象(字符串对象),所以对他操作会高效。

利用virtual dom,将dom抽象为虚拟dom,在dom发生变化的时候先对虚拟dom进行操作,通过dom diff算法将虚拟dom和原虚拟dom的结构做对比,最终批量的去修改真实的dom结构,尽可能的避免了频繁修改dom而导致的频繁的重排和重绘。


10.按要求实现代码

/* 
根据传入参数n(数字)对一维数组(纯数字)按照距离n最近的顺序排序。
(距离即数字与n的差值的绝对值) 
*/ 
var arr = [7, 28, -1, 0, 7, 33]; 
function sort(n) { 
  //your code
}

公司:高思教育

分类:算法

答案&解析

查看解析

代码实现

  • 实现一:排序方法的灵活应用
var arr = [7, 28, -28, 0, 7, 33];
function sort(n) {
    arr.sort((a, b) => {
        return Math.abs(a - n) - Math.abs(b - n);
    })
    console.log(arr);
}
sort(28);
  • 实现二:基于差距进行归并排序O(nlogn)
var nearbySort = function (n, arr) {
  var splitedArr = [];
  for (var i = 0; i < arr.length; i++) {
    splitedArr.push([arr[i]]);
  }
  while(splitedArr.length > 1) { // 两两一组,依次归并 ~ 递归就不用了~
    var half = Math.ceil(splitedArr.length / 2);
    for(var j = 0; j < half; j++) {
      splitedArr[j] = mergeArr(n, splitedArr[j], splitedArr[j + half]);
    }
    console.log(half, splitedArr);
    splitedArr.length = half;
  }
  return (splitedArr.length === 1) ? splitedArr[0] : [];
}
var getDistance = function (n, m){ // 获取n 与 m数值差异(绝对值)
  return n > m ? (n - m) : (m - n);
}
var mergeArr = function(n, left, right){ // left right为已排序数组
  if (!left) {
    return right;
  }
  if (!right) {
    return left;
  }
  var sortedArr = [], leftIndex = 0, rightIndex = 0, leftLen = left.length, rightLen = right.length;
  while(leftIndex < leftLen || rightIndex < rightLen){
    var leftNum = left[leftIndex], rightNum = right[rightIndex];
    if (leftNum === undefined) {
      sortedArr.push(rightNum);
      rightIndex++;
    } else if (rightNum === undefined) {
      sortedArr.push(leftNum);
      leftIndex++;
    } else {
      var leftDistance = getDistance(n, leftNum);
      var rightDistanc = getDistance(n, rightNum);
      if (leftDistance <= rightDistanc) {
        sortedArr.push(leftNum);
        leftIndex++;
      } else {
        sortedArr.push(rightNum);
        rightIndex++;
      }
    }
  }
  return sortedArr;
}
  • 实现三:差距必然只会是固定排序的正整数, 可以创建散列表{0: [], 1: [], ....n: []}进行记录进行最终合并: 复杂度O(n);
var getDistance = function (n, m){ // 获取n 与 m数值差异(绝对值)
  return n > m ? (n - m) : (m - n);
}
var nearbySort = function (n, arr) {
  var distanceObj = {}, len = arr.length, distance, maxDistance = 0, result = [];
  for(var i = 0; i < len; i++){
    distance = getDistance(n, arr[i]);
    if(distance > maxDistance){
      maxDistance = distance;
    }
    if (distanceObj[distance]) {
      distanceObj[distance].push(arr[i]);
    } else {
      distanceObj[distance] = [arr[i]];
    }
  }
  for(var j = 0; j <= maxDistance; j++) { // 两层for, 但内层循环数固定, 总循环固定O(n);
    if (distanceObj[j]) {
      var eachDistance = distanceObj[j];
      var eachLength = eachDistance.length;
      for(var m = 0; m < eachLength; m++) {
        result.push(eachDistance[m])
      }
    }
  }
  return result;
}

还没看过瘾?打开我的刷题小程序:前端面试星球,1000+道前端面试题(包含详细解析&答案)等你解锁。