「查漏补缺」2020 截止9月2日 秋招前端面试经历 (已整理答案)

前言

最近发现CSDN前端小伙伴们也越来越多了,一些前端领域的文章也越来越多,我看到小伙伴们点赞数明显增多了!看来白嫖党还是少了一点点(就那么一点点),我想我也是时候开始出产更优质的前端博文了,立志做一位优秀的前端博主!欢迎大家评论区留言交流~

字节跳动广告系统 面经

一面

自我介绍

手撕防抖(如果滚动条判断一个div是否存在会用什么来做?节流)

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>实现防抖</title>
    <style>
      .container {
        width: 200px;
        height: 200px;
        background-color: aqua;
        font-size: 30px;
        line-height: 200px;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <div class="container"></div>
    <script>
      var btn = document.getElementsByClassName('container')[0]
      // let cnt = 0
      // let timeId = null
      // btn.onmouseover = () => {
      //   clearTimeout(timeId)
      //   timeId = setTimeout(() => {
      //     btn.innerHTML = ++cnt
      //   }, 2000)
      // }
      let cnt = 0
      let doSomething = () => {
        btn.innerHTML = ++cnt
      }
      let debounce = (fn,time,triggerNow) => {
        let timeId = null
        let debounced = (...args) => {
          if(timeId){
              clearTimeout(timeId)
            }
          if(triggerNow){
            let exec = !timeId
            timeId =setTimeout(()=>{
              timeId = null
            },time)
            if(exec){
              fn.apply(this,args)
            }
          }else{
            timeId = setTimeout(()=>{
              fn.apply(this,args)
            },time)
          }
        }
        debounced.remove = () => {
          clearTimeout(timeId)
          timeId = null
        }
        return debounced
      }
      btn.onmouseover = debounce(doSomething,2000,false)
    </script>
  </body>
</html>

CSS实现三角形

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>画三角形</title>
    <style>
        .triggle{
            width: 0px;
            height: 0px;
            border-top: 40px solid transparent;
            border-left: 40px solid transparent;
            border-bottom: 40px solid red;
            border-right: 40px solid transparent;
            margin: 40px;
        }
    </style>
</head>
<body>
    <div class="triggle"></div>
</body>
</html>

了解伪元素和伪类吗?


参考:CSS伪类和伪元素的区别 - Web前端工程师面试题讲解

盒模型

div: 
width=100px;
border: 10px;
padding: 15px;
margin: 10px;
height=100px;

content-box => 100px

border-box => 150px

Vue双向绑定实现 Object.defineProperty() 它有哪些不足点?

  • 只能监听某个属性,不能对全对象进行监听
  • 需要 for in遍历找对象中的属性
  • 不能监听数组,需要单独的对数组进行特异性操作
  • 会污染原对象
data : {
name; 'abc';
age: 23,
},


this.name = 'brown';
this.gender = '男';

如何让 gender 改变也会让视图变化(面试官意思是如何用Vue动态新增对象属性,触发dom渲染)

背景:项目中因为一些需求需要在JSON中新增一个属性,也能console出来,但是就是不能在页面渲染,即不能触发视图更新

其实在vue 中新增属性应该用 $set 这个方法的

1. 添加单个属性

$set()方法,既可以新增属性,又可以触发视图更新。

this.$set(this.data,”key”,value)
2.添加多个属性

使用 Object.assign()用原对象与要混合进去的对象的属性一起创建一个新的对象。

this.obj = Object.assign({}, this.obj, {
  age: 18,
  name: 'Chocolate',
})

询问输出结果

function Foo() {
    getName = function () { alert (1); };
    return this;
}
Foo.getName = function () { alert (2);};
Foo.prototype.getName = function () { alert (3);};
var getName = function () { alert (4);};
function getName() { alert (5);}

//请写出以下输出结果:
Foo.getName();
getName();
Foo().getName();
getName();
new Foo.getName();
new Foo().getName();
new new Foo().getName();

参考:一道常被人轻视的前端JS面试题

第一问

先看此题的上半部分做了什么,首先定义了一个叫Foo的函数,之后为Foo创建了一个叫getName静态属性存储了一个匿名函数,之后为Foo的原型对象新创建了一个叫getName的匿名函数。之后又通过函数变量表达式创建了一个getName的函数,最后再声明一个叫getName函数。

第一问的Foo.getName 自然是访问Foo函数上存储的静态属性,自然是2,没什么可说的。

第二问

第二问,直接调用 getName 函数。既然是直接调用那么就是访问当前上文作用域内的叫getName的函数,所以跟1 2 3都没什么关系。此题有无数面试者回答为5。此处有两个坑,一是变量声明提升,二是函数表达式。

变量声明提升

所有声明变量或声明函数都会被提升到当前函数的顶部。
例如下代码:

console.log('x' in window);//true
var x;
x = 0;

代码执行时js引擎会将声明语句提升至代码最上方,变为:

var x;
console.log('x' in window);//true
x = 0;

函数表达式

var getNamefunction getName 都是声明语句,区别在于var getName函数表达式,而function getName函数声明。关于JS中的各种函数创建方式可以看 大部分人都会做错的经典JS闭包面试题 这篇文章有详细说明。

函数表达式最大的问题,在于js会将此代码拆分为两行代码分别执行。
例如下代码:

console.log(x);//输出:function x(){}
var x=1;
function x(){}

实际执行的代码为,先将 var x=1拆分为var x;x = 1;两行,再将 var x;function x(){}两行提升至最上方变成:

var x;
function x(){}
console.log(x);
x=1;

所以最终函数声明的x覆盖了变量声明的x,log输出为x函数。
同理,原题中代码最终执行时的是:

function Foo() {
    getName = function () { alert (1); };
    return this;
}
var getName;//只提升变量声明
function getName() { alert (5);}//提升函数声明,覆盖var的声明

Foo.getName = function () { alert (2);};
Foo.prototype.getName = function () { alert (3);};
getName = function () { alert (4);};//最终的赋值再次覆盖function getName声明

getName();//最终输出4
第三问

第三问的Foo().getName(); 先执行了Foo函数,然后调用Foo函数的返回值对象的getName属性函数。

Foo函数的第一句 getName = function () { alert (1); }; 是一句函数赋值语句,注意它没有var声明,所以先向当前Foo函数作用域内寻找getName变量,没有。再向当前函数作用域上层,即外层作用域内寻找是否含有getName变量,找到了,也就是第二问中的alert(4)函数,将此变量的值赋值为 function(){alert(1)}

此处实际上是将外层作用域内的getName函数修改了。

注意:此处若依然没有找到会一直向上查找到window对象,若window对象中也没有getName属性,就在window对象中创建一个getName变量。

简单的讲,this的指向是由所在函数的调用方式决定的。而此处的直接调用方式,this指向window对象

遂Foo函数返回的是window对象,相当于执行 window.getName() ,而window中的getName已经被修改为alert(1),所以最终会输出1

此处考察了两个知识点,一个是变量作用域问题,一个是this指向问题

第四问

直接调用getName函数,相当于 window.getName() ,因为这个变量已经被Foo函数执行时修改了,遂结果与第三问相同,为1

第五问

new Foo.getName(); ,此处考察的是js的运算符优先级问题。

js运算符优先级:

通过查上表可以得知点(.)的优先级高于new操作,遂相当于是:

new (Foo.getName)();

所以实际上将getName函数作为了构造函数来执行,遂弹出2

第六问

第六问 new Foo().getName(),首先看运算符优先级括号高于new,并且带参数的new操作符是优先级最高的,实际执行为

(new Foo()).getName()

遂先执行Foo函数,而Foo此时作为构造函数却有返回值,所以这里需要说明下js中的构造函数返回值问题

原题中,返回的是this,而this在构造函数中本来就代表当前实例化对象,遂最终Foo函数返回实例化对象。之后调用实例化对象的getName函数,因为在Foo构造函数中没有为实例化对象添加任何属性,遂到当前对象的原型对象(prototype)中寻找getName,找到了。

遂最终输出3。

第七问

最终实际执行为:

new ((new Foo()).getName)();

先初始化Foo的实例化对象,然后将其原型上的getName函数作为构造函数再次new。

遂最终结果为3

答案
function Foo() {
    getName = function () { alert (1); };
    return this;
}
Foo.getName = function () { alert (2);};
Foo.prototype.getName = function () { alert (3);};
var getName = function () { alert (4);};
function getName() { alert (5);}

//答案:
Foo.getName();//2
getName();//4
Foo().getName();//1
getName();//1
new Foo.getName();//2
new Foo().getName();//3
new new Foo().getName();//3

promise相关,下面代码输出结果

Promise.reject(2).catch(e => e).then(d => {
console.log(d);
});
// 输出2

一般总是建议,Promise 对象后面要跟catch方法,这样可以处理 Promise 内部发生的错误。catch方法返回的还是一个 Promise 对象,因此后面还可以接着调用then方法。

参考:你真的完全掌握了promise么?

Promise 必知必会(十道题)

关于 Promise 的 9 个面试题

cookie 和 session 区别(session 存放哪)

http是一个无状态协议

什么是无状态呢?就是说这一次请求和上一次请求是没有任何关系的,互不认识的,没有关联的。这种无状态的的好处是快速。坏处是假如我们想要把www.zhihu.com/login.htmlwww.zhihu.com/index.html关联起来,必须使用某些手段和工具

cookie和session

由于http的无状态性,为了使某个域名下的所有网页能够共享某些数据,session和cookie出现了。客户端访问服务器的流程如下:

  • 首先,客户端会发送一个http请求到服务器端。
  • 服务器端接受客户端请求后,建立一个session,并发送一个http响应到客户端,这个响应头,其中就包含Set-Cookie头部。该头部包含了sessionId。Set-Cookie格式如下:
    Set-Cookie: value[; expires=date][; domain=domain][; path=path][; secure]
  • 在客户端发起的第二次请求,假如服务器给了set-Cookie,浏览器会自动在请求头中添加cookie
  • 服务器接收请求,分解cookie,验证信息,核对成功后返回response给客户端


注意

  • cookie只是实现session的其中一种方案。虽然是最常用的,但并不是唯一的方法。禁用cookie后还有其他方法存储,比如放在url中
  • 现在大多都是Session + Cookie,但是只用session不用cookie,或是只用cookie,不用session在理论上都可以保持会话状态。可是实际中因为多种原因,一般不会单独使用
  • 用session只需要在客户端保存一个id,实际上大量数据都是保存在服务端。如果全部用cookie,数据量大的时候客户端是没有那么多空间的。
  • 如果只用cookie不用session,那么账户信息全部保存在客户端,一旦被劫持,全部信息都会泄露。并且客户端数据量变大,网络传输的数据量也会变大
拓展:token

token 也称作令牌,由uid+time+sign[+固定参数]
token 的认证方式类似于临时的证书签名, 并且是一种服务端无状态的认证方式, 非常适合于 REST API(表现层状态转换) 的场景. 所谓无状态就是服务端并不会保存身份认证相关的数据。

组成

  • uid: 用户唯一身份标识
  • time: 当前时间的时间戳
  • sign: 签名, 使用 hash/encrypt 压缩成定长的十六进制字符串,以防止第三方恶意拼接
    固定参数(可选): 将一些常用的固定参数加入到 token 中是为了避免重复查库

存放

token在客户端一般存放于localStorage,cookie,或sessionStorage中。在服务器一般存于数据库

token认证流程

token 的认证流程与cookie很相似

  • 用户登录,成功后服务器返回Token给客户端。
  • 客户端收到数据后保存在客户端
  • 客户端再次访问服务器,将token放入headers
  • 服务器端采用filter过滤器校验。校验成功则返回请求数据,校验失败则返回错误码
token可以抵抗csrf,cookie+session不行

假如用户正在登陆银行网页,同时登陆了攻击者的网页,并且银行网页未对csrf攻击进行防护。攻击者就可以在网页放一个表单,该表单提交src为http://www.bank.com/api/transfer,body为count=1000&to=Tom。倘若是session+cookie,用户打开网页的时候就已经转给Tom1000元了.因为form 发起的 POST 请求并不受到浏览器同源策略的限制,因此可以任意地使用其他域的 Cookie 向其他域发送 POST 请求,形成 CSRF 攻击。在post请求的瞬间,cookie会被浏览器自动添加到请求头中。但token不同,token是开发者为了防范csrf而特别设计的令牌,浏览器不会自动添加到headers里,攻击者也无法访问用户的token,所以提交的表单无法通过服务器过滤,也就无法形成攻击。

分布式情况下的session和token

负载均衡多服务器的情况,不好确认当前用户是否登录,因为多服务器不共享session。该解决方案是 session 数据持久化写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。

而token是无状态的,token字符串里就保存了所有的用户信息

客户端登陆传递信息给服务端,服务端收到后把用户信息加密(token)传给客户端,客户端将token存放于localStroage等容器中。客户端每次访问都传递token,服务端解密token,就知道这个用户是谁了。通过cpu加解密,服务端就不需要存储session占用存储空间,就很好的解决负载均衡多服务器的问题了。这个方法叫做JWT(Json Web Token)

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

参考:阮一峰 JSON Web Token 入门教程

总结
  • session存储于服务器,可以理解为一个状态列表,拥有一个唯一识别符号sessionId,通常存放于cookie中。服务器收到cookie后解析出sessionId,再去session列表中查找,才能找到相应session所依赖cookie
  • cookie类似一个令牌,装有sessionId,存储在客户端,浏览器通常会自动添加。
  • token也类似一个令牌,无状态,用户信息都被加密到token中,服务器收到token后解密就可知道是哪个用户。需要开发者手动添加。
  • jwt只是一个跨域认证的方案

如何保护cookie

Secure和HttpOnly

Secure属性意味着把cookie通信限制在加密传输中,指示浏览器只能通过安全/加密连接使用cookie。然而如果一个web服务器在非安全连接中给cookie设置了一个secure属性,这个cookie在发送给用户时仍然可以通过中间人攻击拦截到。因此,为了安全必须通过安全连接设置cookie的Secure属性。

HttpOnly属性指示浏览器除了HTTP/HTTPS请求之外不要显示cookie。这意味着这种cookie不能在客户端通过脚本获取,因此也不会轻易的被跨站脚本窃取。

浏览器设置

大部分浏览器都支持cookie,并且允许用户禁止掉他们。下面是一些常用的选项:

  • 完全允许或者禁止cookie,以便浏览器总是接受或者总是阻止cookie
  • 通过cookie管理器查看或者删除cookie
  • 彻底清除所有的隐私数据,包括cookie

HTTPS中 TLS握手过程简述

参考:三元博客 (传统RSA版本)HTTPS为什么让数据传输更安全?

参考:TLS1.2 握手的过程是怎样的?

Koa 中间件 passport

仿美团项目登录怎么实现的

浏览器缓存 强缓存 和 协商缓存 状态码

(见后文)

结果

一面通过

二面

待更新…

腾讯 QQ音乐 面经

一面

自我介绍

了解ajax跨域嘛?

什么是跨域

回顾一下 URI 的组成:

浏览器遵循同源政策(scheme(协议)host(主机)port(端口) 都相同则为同源)。非同源站点有这样一些限制:

  • 不能读取和修改对方的 DOM
  • 不读访问对方的 Cookie、IndexDB 和 LocalStorage
  • 限制 XMLHttpRequest 请求。(后面的话题着重围绕这个)

当浏览器向目标 URI 发 Ajax 请求时,只要当前 URL 和目标 URL 不同源,则产生跨域,被称为 跨域请求。

跨域请求的响应一般会被浏览器所拦截,注意,是被浏览器拦截,响应其实是成功到达客户端了。那这个拦截是如何发生呢?

首先要知道的是,浏览器是多进程的,以 Chrome 为例,进程组成如下:



WebKit 渲染引擎V8 引擎都在渲染进程当中。

xhr.send 被调用,即 Ajax 请求准备发送的时候,其实还只是在渲染进程的处理。为了防止黑客通过脚本触碰到系统资源,浏览器将每一个渲染进程装进了沙箱,并且为了防止 CPU 芯片一直存在的Spectre 和 Meltdown漏洞,采取了站点隔离的手段,给每一个不同的站点(一级域名不同)分配了沙箱,互不干扰。具体见YouTube上Chromium安全团队的演讲视频

在沙箱当中的渲染进程是没有办法发送网络请求的,那怎么办?只能通过网络进程来发送。那这样就涉及到进程间通信(IPC,Inter Process Communication)了。

总的来说就是利用Unix Domain Socket套接字,配合事件驱动的高性能网络并发库libevent完成进程的 IPC 过程。

好,现在数据传递给了浏览器主进程,主进程接收到后,才真正地发出相应的网络请求。
在服务端处理完数据后,将响应返回,主进程检查到跨域,且没有cors(后面会详细说)响应头,将响应体全部丢掉,并不会发送给渲染进程。这就达到了拦截数据的目的。

cors跨域怎么做?

CORS 其实是 W3C 的一个标准,全称是 跨域资源共享 。它需要浏览器和服务器的共同支持,具体来说,非 IE 和 IE10 以上支持CORS,服务器需要附加特定的响应头,后面具体拆解。不过在弄清楚 CORS 的原理之前,我们需要清楚两个概念: 简单请求非简单请求

浏览器根据请求方法和请求头的特定字段,将请求做了一下分类,具体来说规则是这样,凡是满足下面条件的属于简单请求:

  • 请求方法为 GET、POST 或者 HEAD
  • 请求头的取值范围: Accept、Accept-Language、Content-Language、Content-Type(只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain)

浏览器画了这样一个圈,在这个圈里面的就是简单请求, 圈外面的就是非简单请求,然后针对这两种不同的请求进行不同的处理。

简单请求

请求发出去之前,浏览器做了什么?

它会自动在请求头当中,添加一个Origin字段,用来说明请求来自哪个。服务器拿到请求之后,在回应时对应地添加Access-Control-Allow-Origin字段,如果Origin不在这个字段的范围中,那么浏览器就会将响应拦截。

因此,Access-Control-Allow-Origin字段是服务器用来决定浏览器是否拦截这个响应,这是必需的字段。与此同时,其它一些可选的功能性的字段,用来描述如果不会拦截,这些字段将会发挥各自的作用。

Access-Control-Allow-Credentials。这个字段是一个布尔值,表示是否允许发送 Cookie,对于跨域请求,浏览器对这个字段默认值设为 false,而如果需要拿到浏览器的 Cookie,需要添加这个响应头并设为true, 并且在前端也需要设置withCredentials属性:

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;

Access-Control-Expose-Headers。这个字段是给 XMLHttpRequest 对象赋能,让它不仅可以拿到基本的 6 个响应头字段(包括Cache-Control、Content-Language、Content-Type、Expires、Last-Modified 和 Pragma), 还能拿到这个字段声明的响应头字段。比如这样设置:

Access-Control-Expose-Headers: aaa

那么在前端可以通过 XMLHttpRequest.getResponseHeader('aaa')拿到 aaa 这个字段的值。

非简单请求

非简单请求相对而言会有些不同,体现在两个方面: 预检请求响应字段

我们以 PUT 方法为例。

var url = 'http://xxx.com';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'xxx');
xhr.send();

当这段代码执行后,首先会发送预检请求。这个预检请求的请求行和请求体是下面这个格式:

OPTIONS / HTTP/1.1
Origin: 当前地址
Host: xxx.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header

预检请求的方法是 OPTIONS,同时会加上Origin源地址和Host目标地址,这很简单。同时也会加上两个关键的字段:

  • Access-Control-Request-Method, 列出 CORS 请求用到哪个HTTP方法
  • Access-Control-Request-Headers,指定 CORS 请求将要加上什么请求头

这是预检请求。接下来是响应字段,响应字段也分为两部分,一部分是对于预检请求的响应,一部分是对于 CORS 请求的响应。

预检请求的响应。如下面的格式:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0

其中有这样几个关键的响应头字段:

  • Access-Control-Allow-Origin: 表示可以允许请求的源,可以填具体的源名,也可以填*表示允许任意源请求。
  • Access-Control-Allow-Methods: 表示允许的请求方法列表。
  • Access-Control-Allow-Credentials: 简单请求中已经介绍。
  • Access-Control-Allow-Headers: 表示允许发送的请求头字段
  • Access-Control-Max-Age: 预检请求的有效期,在此期间,不用发出另外一条预检请求。

在预检请求的响应返回后,如果请求不满足响应头的条件,则触发XMLHttpRequestonerror方法,当然后面真正的CORS请求也不会发出去了。

CORS 请求的响应。绕了这么一大转,到了真正的 CORS 请求就容易多了,现在它和简单请求的情况是一样的。浏览器自动加上Origin字段,服务端响应头返回Access-Control-Allow-Origin。可以参考以上简单请求部分的内容。

说说jsonp原理

虽然XMLHttpRequest对象遵循同源政策,但是script标签不一样,它可以通过 src 填上目标地址从而发出 GET 请求,实现跨域请求并拿到响应。这也就是 JSONP 的原理,接下来我们就来封装一个 JSONP:

/* 自定义封装jsonp */
let jsonp = ({ url, params, callbackName }) => {
  let generateUrl = () => {
    let dataStr = ''
    for (let key in params) {
      dataStr += `${key}=${params[key]}`
    }
    dataStr += `callback=${callbackName}`
    return `${url}?${dataStr}`
  }
  return new Promise((resolve, reject) => {
  	// 创建 script 元素并加入到当前文档中
    let scriptEle = document.createElemet('script')
    script.src = generateUrl
    document.body.appendChild(scriptEle)
    try {
      resolve(data)
    } catch (e) {
      reject(e)
    } finally {
    // script 执行完了,成为无用元素,需要清除
      document.body.removeChild(scriptEle)
    }
  })
}

当然在服务端也会有响应的操作, 以 express 为例:

/* 服务端相关操作,以express为例 */
let express = require('express')
let app = express()
const port = '3000'
app.get('/',function(req,res){
  let {a,b,callback} = req.query
  console.log(a)
  console.log(b)
  // 注意,返回给script标签,浏览器直接把这部分字符串执行
  res.send(`${callback}('数据包')`)
})
app;listen(port)

前端这样简单地调用一下就好了:

/* 前端调用 */
jsonp({
  url: 'http://localhost:3000',
  params: {
    a: 1,
    b: 2
  }
}).then(data=>{
  console.log(data) //该数据包
})

CORS相比,JSONP 最大的优势在于兼容性好,IE 低版本不能使用 CORS 但可以使用 JSONP,缺点也很明显,请求方法单一,只支持 GET 请求。

拓展:Nginx

Nginx 是一种高性能的 反向代理 服务器,可以用来轻松解决跨域问题。

正向代理帮助客户端访问客户端自己访问不到的服务器,然后将结果返回给客户端。

反向代理拿到客户端的请求,将请求转发给其他的服务器,主要的场景是维持服务器集群的负载均衡,换句话说,反向代理帮其它的服务器拿到请求,然后选择一个合适的服务器,将请求转交给它。

因此,两者的区别就很明显了,正向代理服务器是帮客户端做事情,而反向代理服务器是帮其它的服务器做事情。

好了,那 Nginx 是如何来解决跨域的呢?

比如说现在客户端的域名为 client.com,服务器的域名为 server.com,客户端向服务器发送 Ajax 请求,当然会跨域了,那这个时候让 Nginx 登场了,通过下面这个配置:

server {
  listen  80;
  server_name  client.com;
  location /api {
    proxy_pass server.com;
  }
}

Nginx 相当于起了一个跳板机,这个跳板机的域名也是 client.com,让客户端首先访问 client.com/api,这当然没有跨域,然后 Nginx 服务器作为反向代理,将请求转发给 server.com,当响应返回时又将响应给到客户端,这就完成整个跨域请求的过程。

还有一些不太常用的方式,了解即可,比如postMessage,当然WebSocket也是一种方式,但是已经不属于 HTTP 的范畴。

如果是用node来做跨域的话,你会怎么做?

(1) 浏览器会自动进行 CORS 通信,实现 CORS 通信的关键是后端。只要后端实现了
CORS,就实现了跨域。
(2) 服务端设置 Access-Control-Allow-Origin 就可以开启 CORS。 该属性表示哪些域名可以访
问资源,如果设置通配符(*)则表示所有网站都可以访问资源。
(3)设置 CORS 和前端没什么关系,但是通过这种方式解决跨域问题的话,会在发送请求时出现两种情况,分
别为简单请求和复杂请求。(上文已经提到)

先以 express 为例
//allow custom header and CORS
app.all('*',function (req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild');
  res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');

  if (req.method == 'OPTIONS') {
    res.send(200); /让options请求快速返回/
  }
  else {
    next();
  }
});

介绍一个 cors 模块,引入就可以解决了。代码如下:

/* express引入cors模块 */
let express = require('express')
let cors = require('cors')
let app = express()

app.use(cors())

app.get('/products/:id', function (req, res, next) {
  res.json({ msg: 'This is CORS-enabled for all origins!' })
})

app.listen(80, function () {
  console.log('CORS-enabled web server listening on port 80')
})
然后,我们再以 koa 为例

服务端: 3001端口

var Koa = require('koa');
var Router = require('koa-router');
const bodyParser = require('koa-bodyparser')
var app = new Koa();
var router = new Router();

app.use(bodyParser()); // 解析body数据
router.options('/test',async (ctx, next) => {
  ctx.set("Access-Control-Allow-Origin", "*");
  ctx.set("Access-Control-Allow-Headers", "Content-Type");
  ctx.set("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
  ctx.set('Access-Control-Allow-Credentials', true);
  ctx.set("Content-Type", "application/json;charset=utf-8");
  ctx.status = 204;
});
router.post('/test',async (ctx, next) => {
  // ctx.router available
  ctx.set("Access-Control-Allow-Origin", "*");
  ctx.set("Access-Control-Allow-Headers", "Content-Type");
  ctx.set("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
  ctx.set('Access-Control-Allow-Credentials', true);
  ctx.set("Content-Type", "application/json;charset=utf-8");
  ctx.body = {
    status: 'success',
    result: ctx.request.body
  };
});

app
  .use(router.routes())
  .use(router.allowedMethods());

app.listen(3001);

客户端:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>测试</title>
</head>
<body>
  <script>
    fetch('http://localhost:3001/test', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json;charset=utf-8'
      },
      body: JSON.stringify({
        data: 'Test'
      })
    }).then(data => {
      console.log(data);
    })
  </script>
</body>
</html>

koa 也有个 cors 模块.代码如下:

/* koa引入cors模块 */
var koa = require('koa')
var route = require('koa-route')
var cors = require('koa-cors')
var app = koa()

app.use(cors())

app.use(
  route.get('/', function () {
    this.body = { msg: 'Hello World!' }
  })
)
app.listen(3000)

怎样给一个新增的dom节点绑定事件?(询问事件代理的作用)

事件委托(事件代理)的作用?
  • 支持为同一个DOM元素注册多个同类型事件
  • 可将事件分成事件捕获和事件冒泡机制

注册多个事件

用以往注册事件的方法,如果存在多个事件,后注册的事件会覆盖先注册的事件

//index.html
<div id="div1"></div>

window.onload = function(){
    let div1 = document.getElementById('div1');
    div1.onclick = function(){
        console.log('打印第一次')
    }
    div1.onclick = function(){
        console.log('打印第二次')
    }
}

可以看到第二个点击注册事件覆盖了第一个注册事件,只执行了console.log(‘打印第二次’);

addEventListener(type,listener,useCapture)实现

  • type: 必须,String类型,事件类型
  • listener: 必须,函数体或者JS方法
  • useCapture: 可选,boolean类型。指定事件是否发生在捕获阶段。默认为false,事件发生在冒泡阶段
<div id="div1"></div>

window.onload = function(){
    let div1 = document.getElementById('div1');
    div1.addEventListener('click',function(){
        console.log('打印第一次')
    })
    div1.addEventListener('click',function(){
        console.log('打印第二次')
    })
}

可以看到两个注册事件都成功触发了。 useCapture是事件委托的关键,我们后面详解

事件捕获和事件冒泡机制

事件捕获

当一个事件触发后,从Window对象触发,不断经过下级节点,直到目标节点。在事件到达目标节点之前的过程就是捕获阶段。所有经过的节点,都会触发对应的事件

事件冒泡

当事件到达目标节点后,会沿着捕获阶段的路线原路返回。同样,所有经过的节点,都会触发对应的事件


通过例子理解两个事件机制:
例子:假设有body和body节点下的div1均有绑定了一个注册事件.
效果:

  • 当为事件捕获(useCapture:true)时,先执行body的事件,再执行div的事件
  • 当为事件冒泡(useCapture:false)时,先执行div的事件,再执行body的事件
//当useCapture为默认false时,为事件冒泡
<body>
    <div id="div1"></div>
</body>

window.onload = function(){
    let body = document.querySelector('body');
    let div1 = document.getElementById('div1');
    body.addEventListener('click',function(){
        console.log('打印body')
    })
    div1.addEventListener('click',function(){
        console.log('打印div1')
    })
}
//结果:打印div1  打印body

//当useCapture为true时,为事件捕获
<body>
    <div id="div1"></div>
</body>

window.onload = function(){
    let body = document.querySelector('body');
    let div1 = document.getElementById('div1');
    body.addEventListener('click',function(){
        console.log('打印body')
    },true)
    div1.addEventListener('click',function(){
        console.log('打印div1')
    })
}

//结果:打印body   打印div1

事件委托和新增节点绑定事件的关系?

事件委托的优点:

  • 提高性能: 每一个函数都会占用内存空间,只需添加一个事件处理程序代理所有事件,所占用的内存空间更少。
  • 动态监听: 使用事件委托可以自动绑定动态添加的元素,即新增的节点不需要主动添加也可以一样具有和其他元素一样的事件。

例子解析:

<script>
    window.onload = function(){
        let div = document.getElementById('div');
        
        div.addEventListener('click',function(e){
            console.log(e.target)
        })
        
        let div3 = document.createElement('div');
        div3.setAttribute('class','div3')
        div3.innerHTML = 'div3';
        div.appendChild(div3)
    }
</script>


<body>
    <div id="div">
        <div class="div1">div1</div>
        <div class="div2">div2</div>
    </div>
</body>

虽然没有给div1和div2添加点击事件,但是无论是点击div1还是div2,都会打印当前节点。因为其父级绑定了点击事件,点击div1后冒泡上去的时候,执行父级的事件。

这样无论后代新增了多少个节点,一样具有这个点击事件的功能。

了解浏览器缓存吗?(强缓存、协商缓存)你怎样更新强缓存呢?

1.强缓存

强缓存是指直接通过本地缓存获取资源,不用经过服务器

常用字段:

  • expires
    值为一个绝对时间的 GMT 格式的时间字符串,如果发送请求的时间在 expires 之前,那么本地缓存有效,否则就会发送请求到服务器来获取资源。

缺点:无法保证客户端按照标准时间设定

  • Cache-Control(常用值如下):

max-age:允许的最大缓存秒数
no-store:不允许使用缓存,每次都要向服务器获取
no-cache:不允许使用本地缓存,每次都要向服务器进行协商缓存
public:允许被所有中间代理和终端浏览器缓存
private:只允许被终端浏览器缓存
Cache-Control 比 expires 优先级高

2.协商缓存

协商缓存是指客户端向服务端确认资源是否用

常用字段:

  • Last-Modified / If-Modified-Since:

值是 GMT 格式的时间字符串,具体流程如下:

浏览器第一次请求资源,服务端返回 Last-Modified,表示资源在服务端的最后修改时间。
浏览器第二次请求的时候会在请求头上携带If-Modified-Since,值为上次返回的 Last-Modified
服务端收到请求后,比较保存的 Last-Modified 和 请求报文中的 If-Modified-Since,如果一致就返回 304 状态码,不一致就返回新资源,同时更新 Last-Modified

  • ETag / If-None-Match

值是服务器生成的资源标识符,当资源修改后这个值会被改变,

具体流程与 Last-Modified、If-Modified-Since 相似,但与 Last-Modified 不一样的是,当服务器返回304的响应时,由于 ETag 重新生成过,response header中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化。

既生 Last-Modified 何生 Etag
  • 一些文件也许会周期性的更改,但是他的内容并不改变(仅仅改变的修改时间),这个时候我们并不希望客户端认为这个文件被修改了,我们就可以使用 Etag 来做

  • 某些文件修改非常频繁,比如在秒以下的时间内进行修改,(比方说1s内修改了N次),If-Modified-Since 能检查到的粒度是s级的,这种修改无法判断

最佳实践

缓存的意义就在于减少请求,更多地使用本地的资源,给用户更好的体验的同时,也减轻服务器压力。所以,最佳实践,就应该是尽可能命中强缓存,同时,能在更新版本的时候让客户端的缓存失效。

在更新版本之后,如何让用户第一时间使用最新的资源文件呢?机智的前端们想出了一个方法,在更新版本的时候,顺便把静态资源的路径改了,这样,就相当于第一次访问这些资源,就不会存在缓存的问题了。


伟大的webpack可以让我们在打包的时候,在文件的命名上带上 hash

entry:{
    main: path.join(__dirname,'./main.js'),
    vendor: ['react', 'antd']
},
output:{
    path:path.join(__dirname,'./dist'),
    publicPath: '/dist/',
    filname: 'bundle.[chunkhash].js'
}

综上所述,我们可以得出一个较为合理的缓存方案:

  • HTML:使用协商缓存。
  • CSS&JS&图片:使用强缓存,文件命名带上hash值。
哈希值计算方式

webpack给我们提供了三种哈希值计算方式,分别是 hashchunkhashcontenthash。那么这三者有什么区别呢?

  • hash:跟整个项目的构建相关,构建生成的文件hash值都是一样的,只要项目里有文件更改,整个项目构建的hash值都会更改。
  • chunkhash:根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的hash值。
  • contenthash:由文件内容产生的hash值,内容不同产生的contenthash值也不一样。

显然,我们是不会使用第一种的。改了一个文件,打包之后,其他文件的hash都变了,缓存自然都失效了。这不是我们想要的。

chunkhashcontenthash 的主要应用场景是什么呢?在实际在项目中,我们一般会把项目中的css都抽离出对应的css文件来加以引用。如果我们使用chunkhash,当我们改了css代码之后,会发现css文件hash值改变的同时,js文件的hash值也会改变。这时候,contenthash就派上用场了。

补充:后端需要怎么设置

上文主要说的是前端如何进行打包,那后端怎么做呢? 我们知道,浏览器是根据响应头的相关字段来决定缓存的方案的。所以,后端的关键就在于,根据不同的请求返回对应的缓存字段。 以nodejs为例,如果需要浏览器强缓存,我们可以这样设置:

res.setHeader('Cache-Control', 'public, max-age=xxx');

如果需要协商缓存,则可以这样设置:

res.setHeader('Cache-Control', 'public, max-age=0');
res.setHeader('Last-Modified', xxx);
res.setHeader('ETag', xxx);

在做前端缓存时,我们尽可能设置长时间的强缓存,通过文件名加hash的方式来做版本更新。在代码分包的时候,应该将一些不常变的公共库独立打包出来,使其能够更持久的缓存。

HTTP缓存

缓存配置

方案如下:

  • 方案1:cache-control: no-store:不缓存,每次访问都从服务下载所有资源。
  • 方案2:cache-control: no-cache或cache-control: max-age=0:对比缓存,缓存当前资源,但每次访问都需要跟服务器对比,检查资源是否被修改。(等同于expires = 过去的时间或无效时间,缓存但立即过期)
  • 方案3:cache-control: max-age=seconds //seconds > 0:强缓存,缓存当前资源,在一定时期内,再次请求资源直接读取本地缓存。

注:强缓存下资源也并非不可更新,例如chrome的ctrl + f5等同于直接触发方案1,f5或者webview的刷新键会直接触发方案2,但都是基于客户端操作,不建议纳入实际项目考虑。

实际项目中,方案1的应用基本上看不到,对比方案2和方案3,方案1没有任何优势。在方案2和方案3的选择中,我们会对资源作区分。

  • 对于img,css,js,fonts等非html资源,我们可以直接考虑方案3,并且max-age配置的时间可以尽可能久,类似于缓存规则案例中,cache-control: max-age=31535000 配置365天的缓存,需要注意的是,这样配置并不代表这些资源就一定一年不变,其根本原因在于目前前端构建工具在静态资源中都会加入戳的概念(例如,webpack中的[hash],gulp中的gulp-rev),每次修改均会改变文件名或增加query参数,本质上改变了请求的地址,也就不存在缓存更新的问题。

  • 对于html资源,我们建议根据项目的更新频度来确定采用哪套方案。html作为前端资源的入口文件,一旦被强缓存,那么相关的js,css,img等均无法更新。对于高频维护的业务类项目,建议采用方案2,或是方案3但max-age设置一个较小值,例如3600,一小时过期。对于一些活动项目,上线后不会进行较大改动,建议采用方案3,不过max-age也不要设置过大,否则一旦出现bug或是未知问题,用户无法及时更新。

除了以上考虑,有时候其他因素也会影响缓存的配置,例如QQ红包除夕活动,高并发大流量很容易给服务器带来极大挑战,这时我们作为前端开发,就可以采用方案3来避免用户多次进入带来的流量压力。

对于http缓存的配置,我们始终要做到两点,一是清楚明白http缓存的原理与规则,二是明确缓存的配置不是一次性的,根据不同的情况配置不同的规则,才能够更好的发挥http缓存的价值。

cdn缓存

cdn缓存是一种服务端缓存,CDN服务商将源站的资源缓存到遍布全国的高性能加速节点上,当用户访问相应的业务资源时,用户会被调度至最接近的节点最近的节点ip返回给用户,在web性能优化中,它主要起到了,缓解源站压力,优化不同用户的访问速度与体验的作用。

缓存规则

与http缓存规则不同的是,这个规则并不是规范性的,而是由cdn服务商来制定,我们以腾讯云举例,打开cdn加速服务配置,面板如下。


可以看到,提供给我们的配置项只有文件类型(或文件目录)和刷新时间,意义也很简单,针对不同文件类型,在cdn节点上缓存对应的时间。

cdn运作流程


由图我们可以看出,cdn缓存的配置主要作用在缓存处理阶段,虽然配置项只有文件类型和缓存时间,但流程却并不简单,我们先来明确一个概念——回源,回源的意思就是返回源站,何为源站,就是我们自己的服务器,很多人误解接入cdn就是把资源放在了cdn上,其实不然,如图中所示,接入cdn后,我们的服务器就是源站,源站一般情况下只会在cdn节点没有资源或cdn资源失效时接收到cdn节点的请求,其他时间,源站并不会接收请求(当然,如果我们知道源站的地址,我们可以直接访问源站)。明确了回源的概念后,cdn的流程就显得不那么复杂了,简单的理解就是, 没有资源就去源站读取,有资源就直接发送给用户。 与http缓存不同的是,cdn中没有no-cache(max-age=0)的情况,当我们设置缓存时间为0的时候,该类型文件就被认定为不缓存文件,就是所有请求直接转发源站,只有当缓存时间大于0且缓存过期的时候,才会与源站对比缓存是否被修改。

缓存配置

各个cdn服务商并不完全一致,以腾讯云为例,在缓存配置的文档中特别有以下说明。


这会对我们有什么影响呢?

  • 如果我们http缓存设置cache-control: max-age=600,即缓存10分钟,但cdn缓存配置中设置文件缓存时间为1小时,那么就会出现如下情况,文件被访问后第12分钟修改并上传到服务器,用户重新访问资源,响应码会是304,对比缓存未修改,资源依然是旧的,一个小时后再次访问才能更新为最新资源

  • 如果不设置cache-control呢,在http缓存中我们说过,如果不设置cache-control,那么会有默认的缓存时间,但在这里,cdn服务商明确会在没有cache-control字段时主动帮我们添加cache-control: max-age=600。

注:针对问题1,也并非没有办法,当我们必须要在缓存期内修改文件,并且不想影响用户体验,那么我们可以使用cdn服务商提供的强制更新缓存功能,主要注意的是,这里的强制更新是更新服务端缓存,http缓存依然按照http头部规则进行自己的缓存处理,并不会受到影响

http缓存与cdn缓存的结合


当用户访问我们的业务服务器时,首先进行的就是http缓存处理,如果http缓存通过校验,则直接响应给用户,如果未通过校验,则继续进行cdn缓存的处理,cdn缓存处理完成后返回给客户端,由客户端进行http缓存规则存储并响应给用户。

总结

CDN的原理

首先,浏览器会先请求CDN域名,CDN域名服务器会给浏览器返回指定域名的CNAME,浏览器在对这些CNAME进行解析,得到CDN缓存服务器的IP地址,浏览器在去请求缓存服务器,CDN缓存服务器根据内部专用的DNS解析得到实际IP,然后缓存服务器会向实际IP地址请求资源,缓存服务器得到资源后进行本地保存和返回给浏览器客户端。

如何检测JS错误,如何保证你的产品质量?(错误监控)(仅仅答了window.onerror)跨域的js运行错误可以捕获吗,错误提示什么,应该怎么处理?

script error 由来

我们的页面往往将静态资源( js、css、image )存放到第三方 CDN,或者依赖于外部的静态资源。当从第三方加载的 javascript 执行出错时,由于同源策略,为了保证用户信息不被泄露,不会返回详细的错误信息,取之返回 script error。

webkit 源码:

bool ScriptExecutionContext::sanitizeScriptError(String& errorMessage, int& lineNumber, String& sourceURL) {
  KURL targetURL = completeURL(sourceURL);

  if (securityOrigin()->canRequest(targetURL)) return false;
  // 非同源,将相关的错误信息设置成默认,错误信息置为 Script error,行号置成0
  errorMessage = "Script error.";
  sourceURL = String();
  ineNumber = 0;
  return true;
}

bool ScriptExecutionContext::dispatchErrorEvent(const String& errorMessage, int lineNumber, const String& sourceURL) {
  EventTarget* target = errorEventTarget();
  if (!target) return false;
  String message = errorMessage;
  int line = lineNumber;
  String sourceName = sourceURL;
  sanitizeScriptError(message, line, sourceName);
  ASSERT(!m_inDispatchErrorEvent);
  m_inDispatchErrorEvent = true;
  RefPtr<ErrorEvent> errorEvent = ErrorEvent::create(message, sourceName, line);
  target->dispatchEvent(errorEvent);
  m_inDispatchErrorEvent = false;
  return errorEvent->defaultPrevented();
}
常见的解决方案
  • 开启 CORS 跨域资源共享

a) 添加 crossorigin=“anonymous” 属性:

<script src="http://domain/path/*.js" crossorigin="anonymous"></script>

当有 crossorigin=“anonymous”,浏览器以匿名的方式获取目标脚本,请求脚本时不会向服务器发送用户信息( cookie、http 证书等)。

b) 此时静态服务器需要添加跨域协议头:

Access-Control-Allow-Origin: *

完成这两步后 window.onerror 就能够捕获对应跨域脚本发生错误时的详细错误信息了。

  • try catch

crossorigin=“anonymous” 确实可以完美解决 badjs 上报 script error 问题,但是需要服务端进行跨域头支持,而往往在大型企业,域名多的令人发指,导致跨域规则配置非常复杂,所以很难全部都配置上,而且依赖的一些外部资源也不能确保支持,所以我们在调用外部资源方法以及一些不确认是否配置跨域头的资源方法时采用 try catch 包装,并在 catch 到问题时上报对应的错误

function invoke(obj, method, args) {
  try {
    return obj[method].apply(this, args);
  } catch (e) {
    reportBadjs(e); // report the error
  }
}

参考:前端 JavaScript 错误分析实践

结果

一面凉经,继续努力。

阿里智能事业群-达摩院-机器智能技术部

一面

自我介绍

聊实习经历

主要工作内容,比如申请前端小组组长,与项目负责人沟通,完成模块发布工具前端框架。每日完成工作进度汇报,前端工作主要是表格页面渲染,比如树形表格,分组表格结合等,实现基本的增删改查功能…

项目中有用到SSR,说说对SSR的理解,目前为什么要用SSR?

页面的渲染流程
  • 浏览器通过请求得到一个HTML文本
  • 渲染进程解析HTML文本,构建DOM树
  • 解析HTML的同时,如果遇到内联样式或者样式脚本,则下载并构建样式规则(stytle rules),若遇到JavaScript脚本,则会下载执行脚本。
  • DOM树和样式规则构建完成之后,渲染进程将两者合并成渲染树(render tree)
  • 渲染进程开始对渲染树进行布局,生成布局树(layout tree)
  • 渲染进程对布局树进行绘制,生成绘制记录
  • 渲染进程的对布局树进行分层,分别栅格化每一层,并得到合成帧
  • 渲染进程将合成帧信息发送给GPU进程显示到页面中

可以看到,页面的渲染其实就是浏览器将HTML文本转化为页面帧的过程。而如今我们大部分WEB应用都是使用 JavaScript 框架(Vue、React、Angular)进行页面渲染的,也就是说,在执行 JavaScript 脚本的时候,HTML页面已经开始解析并且构建DOM树了,JavaScript 脚本只是动态的改变 DOM 树的结构,使得页面成为希望成为的样子,这种渲染方式叫动态渲染,也可以叫客户端渲染(client side rende)

那么什么是服务端渲染(server side render)?顾名思义,服务端渲染就是在浏览器请求页面URL的时候,服务端将我们需要的HTML文本组装好,并返回给浏览器,这个HTML文本被浏览器解析之后,不需要经过 JavaScript 脚本的执行,即可直接构建出希望的 DOM 树并展示到页面中。这个服务端组装HTML的过程,叫做服务端渲染

服务端渲染的由来

Web1.0

在没有AJAX的时候,也就是web1.0时代,几乎所有应用都是服务端渲染(此时服务器渲染非现在的服务器渲染),那个时候的页面渲染大概是这样的,浏览器请求页面URL,然后服务器接收到请求之后,到数据库查询数据,将数据丢到后端的组件模板(php、asp、jsp等)中,并渲染成HTML片段,接着服务器在组装这些HTML片段,组成一个完整的HTML,最后返回给浏览器,这个时候,浏览器已经拿到了一个完整的被服务器动态组装出来的HTML文本,然后将HTML渲染到页面中,过程没有任何JavaScript代码的参与。


客户端渲染

在WEB1.0时代,服务端渲染看起来是一个当时的最好的渲染方式,但是随着业务的日益复杂和后续AJAX的出现,也渐渐开始暴露出了WEB1.0服务器渲染的缺点。

  • 每次更新页面的一小的模块,都需要重新请求一次页面,重新查一次数据库,重新组装一次HTML
  • 前端JavaScript代码和后端(jsp、php、jsp)代码混杂在一起,使得日益复杂的WEB应用难以维护

而且那个时候,根本就没有前端工程师这一职位,前端js的活一般都由后端同学 jQuery 一把梭。但是随着前端页面渐渐地复杂了之后,后端开始发现js好麻烦,虽然很简单,但是坑太多了,于是让公司招聘了一些专门写js的人,也就是前端,这个时候,前后端的鄙视链就出现了,后端鄙视前端,因为后端觉得js太简单,无非就是写写页面的特效(JS),切切图(CSS),根本算不上是真正的程序员。

随之 nodejs 的出现,前端看到了翻身的契机,为了摆脱后端的指指点点,前端开启了一场前后端分离的运动,希望可以脱离后端独立发展。前后端分离,表面上看上去是代码分离,实际上是为了前后端人员分离,也就是前后端分家,前端不再归属于后端团队。

前后端分离之后,网页开始被当成了独立的应用程序(SPA,Single Page Application),前端团队接管了所有页面渲染的事,后端团队只负责提供所有数据查询与处理的API,大体流程是这样的:首先浏览器请求URL,前端服务器直接返回一个空的静态HTML文件(不需要任何查数据库和模板组装),这个HTML文件中加载了很多渲染页面需要的 JavaScript 脚本和 CSS 样式表,浏览器拿到 HTML 文件后开始加载脚本和样式表,并且执行脚本,这个时候脚本请求后端服务提供的API,获取数据,获取完成后将数据通过JavaScript脚本动态的将数据渲染到页面中,完成页面显示。


这一个前后端分离的渲染模式,也就是客户端渲染(CSR)

服务端渲染

随着单页应用(SPA)的发展,程序员们渐渐发现SEO(Search Engine Optimazition,即搜索引擎优化)出了问题,而且随着应用的复杂化,JavaScript 脚本也不断的臃肿起来,使得首屏渲染相比于 Web1.0时候的服务端渲染,也慢了不少。

自己选的路,跪着也要走下去。于是前端团队选择了使用 nodejs 在服务器进行页面的渲染,进而再次出现了服务端渲染。大体流程与客户端渲染有些相似,首先是浏览器请求URL,前端服务器接收到URL请求之后,根据不同的URL,前端服务器向后端服务器请求数据,请求完成后,前端服务器会组装一个携带了具体数据的HTML文本,并且返回给浏览器,浏览器得到HTML之后开始渲染页面,同时,浏览器加载并执行 JavaScript 脚本,给页面上的元素绑定事件,让页面变得可交互,当用户与浏览器页面进行交互,如跳转到下一个页面时,浏览器会执行 JavaScript 脚本,向后端服务器请求数据,获取完数据之后再次执行 JavaScript 代码动态渲染页面。


参考:【万字长文警告】从头到尾彻底理解服务端渲染SSR原理

参考:为什么现在又流行服务端渲染html?

Vue.js 服务器端渲染指南

服务端渲染的利弊

利于SEO

有利于SEO,其实就是有利于爬虫来爬你的页面,然后在别人使用搜索引擎搜索相关的内容时,你的网页排行能靠得更前,这样你的流量就有越高。那为什么服务端渲染更利于爬虫爬你的页面呢?其实,爬虫也分低级爬虫和高级爬虫。

  • 低级爬虫:只请求URL,URL返回的HTML是什么内容就爬什么内容。
  • 高级爬虫:请求URL,加载并执行JavaScript脚本渲染页面,爬JavaScript渲染后的内容

也就是说,低级爬虫对客户端渲染的页面来说,简直无能为力,因为返回的HTML是一个空壳,它需要执行 JavaScript 脚本之后才会渲染真正的页面。而目前像百度、谷歌、微软等公司,有一部分年代老旧的爬虫还属于低级爬虫,使用服务端渲染,对这些低级爬虫更加友好一些。

白屏时间更短

相对于客户端渲染,服务端渲染在浏览器请求URL之后已经得到了一个带有数据的HTML文本,浏览器只需要解析HTML,直接构建DOM树就可以。而客户端渲染,需要先得到一个空的HTML页面,这个时候页面已经进入白屏,之后还需要经过加载并执行 JavaScript、请求后端服务器获取数据、JavaScript 渲染页面几个过程才可以看到最后的页面。特别是在复杂应用中,由于需要加载 JavaScript 脚本,越是复杂的应用,需要加载的 JavaScript 脚本就越多、越大,这会导致应用的首屏加载时间非常长,进而降低了体验感。

服务端渲染缺点

并不是所有的WEB应用都必须使用SSR,这需要开发者自己来权衡,因为服务端渲染会带来以下问题:

  • 代码复杂度增加。为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况,而一部分依赖的外部扩展库却只能在客户端运行,需要对其进行特殊处理,才能在服务器渲染应用程序中运行。
  • 需要更多的服务器负载均衡。由于服务器增加了渲染HTML的需求,使得原本只需要输出静态资源文件的nodejs服务,新增了数据获取的IO和渲染HTML的CPU占用,如果流量突然暴增,有可能导致服务器down机,因此需要使用响应的缓存策略和准备相应的服务器负载。
  • 及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。

所以在使用服务端渲染SSR之前,需要开发者考虑投入产出比,比如大部分应用系统都不需要SEO,而且首屏时间并没有非常的慢,如果使用SSR反而小题大做了。

同构

在服务端渲染中,有两种页面渲染的方式:

  • 前端服务器通过请求后端服务器获取数据并组装HTML返回给浏览器,浏览器直接解析HTML后渲染页面
  • 浏览器在交互过程中,请求新的数据并动态更新渲染页面

这两种渲染方式有一个不同点就是,一个是在服务端中组装html的,一个是在客户端中组装html的,运行环境是不一样的。所谓同构,就是让一份代码,既可以在服务端中执行,也可以在客户端中执行,并且执行的效果都是一样的,都是完成这个html的组装,正确的显示页面。也就是说,一份代码,既可以客户端渲染,也可以服务端渲染。

同构的条件

为了实现同构,我们需要满足什么条件呢?

首先,我们思考一个应用中一个页面的组成,假如我们使用的是Vue.js,当我们打开一个页面时,首先是打开这个页面的URL,这个URL,可以通过应用的路由匹配,找到具体的页面,不同的页面有不同的视图,那么,视图是什么?从应用的角度来看,视图 = 模板 + 数据,那么在 Vue.js 中, 模板可以理解成组件,数据可以理解为数据模型,即响应式数据。所以,对于同构应用来说,我们必须实现客户端与服务端的路由、模型组件、数据模型的共享

总结

浏览器渲染

单页应用用的基本都是浏览器渲染。优点很明确,后端只提供数据,前端做视图和交互逻辑,分工明确。服务器只提供接口,路由以及渲染都丢给前端,服务器计算压力变轻了。但是弱点就是用户等待时间变长了,尤其在请求数多而且有一定先后顺序的时候。

客户端渲染路线:1. 请求一个html -> 2. 服务端返回一个html -> 3. 浏览器下载html里面的js/css文件 -> 4. 等待js文件下载完成 -> 5. 等待js加载并初始化完成 -> 6. js代码终于可以运行,由js代码向后端请求数据( ajax/fetch ) -> 7. 等待后端数据返回 -> 8. 客户端从无到完整地,把数据渲染为响应页面

服务器渲染

服务器接到用户请求之后,计算出用户需要的数据,然后将数据更新成视图(也就是一串dom字符)发给客户端,客户端直接将这串字符塞进页面即可。这样做的好处是响应很快,用户体验会比较好,另外对于搜索引擎来说也是友好的,有SEO优化。nodejs层的服务器渲染,还有一个明显的好处就是前端性能优化更顺手了,可操作的空间大了。但是缺点也很明显,如果不是增加一个node层的话,前后端责任分工不明,不能很好的并行开发。另外也增加了服务器计算压力(虽然可以做渲染缓存,但毕竟是多做了计算)。

服务端渲染路线:2. 请求一个html -> 2. 服务端请求数据( 内网请求快 ) -> 3. 服务器初始渲染(服务端性能好,较快) -> 4. 服务端返回已经有正确内容的页面 -> 5. 客户端请求js/css文件 -> 6. 等待js文件下载完成 -> 7. 等待js加载并初始化完成 -> 8. 客户端把剩下一部分渲染完成( 内容小,渲染快 )

对CDN的理解

github 里面有做cdn仓库,于是扯到了cdn,之后挂钩上http缓存,浏览器缓存相关,回源那一块。

说一个最近刷的印象比较深刻的 leetcode 题目,讲讲思路

leetcode 200 岛屿问题 讲了怎么dfs 沉岛

大学里面学的一些课程哪门最熟悉?

算法、数据结构、计算机网络、操作系统

选了计算机网络,毕竟是班主任教的(orz)

说说五层、七层 计算机网络模型

参考:详解 四层、五层、七层 计算机网络模型

举例传输层和应用层

传输层:TCP / UDP
应用层:HTTP / HTTPS 、FTP、SMTP等

HTTP1.0 和 HTTP2.0区别有了解吗?

简要概括一下 HTTP 的特点?HTTP 有哪些缺点?

HTTP 特点

  • 灵活可扩展
    主要体现在两个方面。一个是语义上的自由,只规定了基本格式,比如空格分隔单词,换行分隔字段,其他的各个部分都没有严格的语法限制。另一个是传输形式的多样性,不仅仅可以传输文本,还能传输图片、视频等任意数据,非常方便。

  • 可靠传输
    HTTP 基于 TCP/IP,因此把这一特性继承了下来。

  • 请求-应答
    也就是一发一收、有来有回, 当然这个请求方和应答方不单单指客户端和服务器之间,如果某台服务器作为代理来连接后端的服务端,那么这台服务器也会扮演请求方的角色。

  • 无状态
    这里的状态是指通信过程的上下文信息,而每次 http 请求都是独立、无关的,默认不需要保留状态信息。

HTTP 缺点

  • 无状态

所谓的优点和缺点还是要分场景来看的,对于 HTTP 而言,最具争议的地方在于它的无状态

需要长连接的场景中,需要保存大量的上下文信息,以免传输大量重复的信息,那么这时候无状态就是 http 的缺点了。

但与此同时,另外一些应用仅仅只是为了获取一些数据,不需要保存连接上下文信息,无状态反而减少了网络开销,成为了 http 的优点。

  • 明文传输

即协议里的报文(主要指的是头部)不使用二进制数据,而是文本形式
这当然对于调试提供了便利,但同时也让 HTTP 的报文信息暴露给了外界,给攻击者也提供了便利。WIFI陷阱就是利用 HTTP 明文传输的缺点,诱导你连上热点,然后疯狂抓你所有的流量,从而拿到你的敏感信息。

  • 队头阻塞问题

当 http 开启长连接时,共用一个 TCP 连接,同一时刻只能处理一个请求,那么当前请求耗时过长的情况下,其它的请求只能处于阻塞状态,也就是著名的队头阻塞问题。

参考:三元大佬(建议精读)HTTP灵魂之问,巩固你的 HTTP 知识体系

HTTP1.1 如何解决 HTTP 的队头阻塞问题?

什么是 HTTP 队头阻塞?

HTTP 传输是基于请求-应答的模式进行的,报文必须是一发一收,但值得注意的是,里面的任务被放在一个任务队列中串行执行,一旦队首的请求处理太慢,就会阻塞后面请求的处理。这就是著名的HTTP队头阻塞问题。

  • 并发连接

对于一个域名允许分配多个长连接,那么相当于增加了任务队列,不至于一个队伍的任务阻塞其它所有任务。在RFC2616规定过客户端最多并发 2 个连接,不过事实上在现在的浏览器标准中,这个上限要多很多,Chrome 中是 6 个

但其实,即使是提高了并发连接,还是不能满足人们对性能的需求。

  • 域名分片

一个域名不是可以并发 6 个长连接吗?那我就多分几个域名。

比如 content1.sanyuan.com 、content2.sanyuan.com。

这样一个sanyuan.com域名下可以分出非常多的二级域名,而它们都指向同样的一台服务器,能够并发的长连接数更多了,事实上也更好地解决了队头阻塞的问题。

HTTP/2 有哪些改进?

由于 HTTPS 在安全方面已经做的非常好了,HTTP 改进的关注点放在了性能方面。对于 HTTP/2 而言,它对于性能的提升主要在于两点:

  • 头部压缩
  • 多路复用

当然还有一些颠覆性的功能实现:

  • 设置请求优先级
  • 服务器推送

这些重大的提升本质上也是为了解决 HTTP 本身的问题而产生的。接下来我们来看看 HTTP/2 解决了哪些问题,以及解决方式具体是如何的。

头部压缩

在 HTTP/1.1 及之前的时代,请求体一般会有响应的压缩编码过程,通过Content-Encoding头部字段来指定,但你有没有想过头部字段本身的压缩呢?当请求字段非常复杂的时候,尤其对于 GET 请求,请求报文几乎全是请求头,这个时候还是存在非常大的优化空间的。HTTP/2 针对头部字段,也采用了对应的压缩算法——HPACK,对请求头进行压缩。

HPACK 算法是专门为 HTTP/2 服务的,它主要的亮点有两个:

  • 首先是在服务器和客户端之间建立哈希表,将用到的字段存放在这张表中,那么在传输的时候对于之前出现过的值,只需要把索引(比如0,1,2,…)传给对方即可,对方拿到索引查表就行了。这种传索引的方式,可以说让请求头字段得到极大程度的精简和复用。

HTTP/2 当中废除了起始行的概念,将起始行中的请求方法、URI、状态码转换成了头字段,不过这些字段都有一个":"前缀,用来和其它请求头区分开。

  • 其次是对于整数和字符串进行哈夫曼编码,哈夫曼编码的原理就是先将所有出现的字符建立一张索引表,然后让出现次数多的字符对应的索引尽可能短,传输的时候也是传输这样的索引序列,可以达到非常高的压缩率。

多路复用

我们之前讨论了 HTTP 队头阻塞的问题,其根本原因在于HTTP 基于请求-响应的模型,在同一个 TCP 长连接中,前面的请求没有得到响应,后面的请求就会被阻塞。

后面我们又讨论到用并发连接域名分片的方式来解决这个问题,但这并没有真正从 HTTP 本身的层面解决问题,只是增加了 TCP 连接,分摊风险而已。而且这么做也有弊端,多条 TCP 连接会竞争有限的带宽,让真正优先级高的请求不能优先处理。

而 HTTP/2 便从 HTTP 协议本身解决了队头阻塞问题。注意,这里并不是指的TCP队头阻塞,而是HTTP队头阻塞,两者并不是一回事。TCP 的队头阻塞是在数据包层面,单位是数据包,前一个报文没有收到便不会将后面收到的报文上传给 HTTP,而HTTP 的队头阻塞是在 HTTP 请求-响应层面,前一个请求没处理完,后面的请求就要阻塞住。两者所在的层次不一样。

那么 HTTP/2 如何来解决所谓的队头阻塞呢?

二进制分帧

首先,HTTP/2 认为明文传输对机器而言太麻烦了,不方便计算机的解析,因为对于文本而言会有多义性的字符,比如回车换行到底是内容还是分隔符,在内部需要用到状态机去识别,效率比较低。于是 HTTP/2 干脆把报文全部换成二进制格式,全部传输01串,方便了机器的解析。

原来Headers + Body的报文格式如今被拆分成了一个个二进制的帧,用Headers帧存放头部字段,Data帧存放请求体数据。分帧之后,服务器看到的不再是一个个完整的 HTTP 请求报文,而是一堆乱序的二进制帧。这些二进制帧不存在先后关系,因此也就不会排队等待,也就没有了 HTTP 的队头阻塞问题

通信双方都可以给对方发送二进制帧,这种二进制帧的双向传输的序列,也叫做流(Stream)。HTTP/2 用来在一个 TCP 连接上来进行多个数据帧的通信,这就是多路复用的概念。

可能你会有一个疑问,既然是乱序首发,那最后如何来处理这些乱序的数据帧呢?

首先要声明的是,所谓的乱序,指的是不同 ID 的 Stream 是乱序的,但同一个 Stream ID 的帧一定是按顺序传输的。二进制帧到达后对方会将 Stream ID 相同的二进制帧组装成完整的请求报文响应报文。当然,在二进制帧当中还有其他的一些字段,实现了优先级和流量控制等功能,我们放到下一节再来介绍。

服务器推送

另外值得一说的是 HTTP/2 的服务器推送(Server Push)。在 HTTP/2 当中,服务器已经不再是完全被动地接收请求,响应请求,它也能新建 stream 来给客户端发送消息,当 TCP 连接建立之后,比如浏览器请求一个 HTML 文件,服务器就可以在返回 HTML 的基础上,将 HTML 中引用到的其他资源文件一起返回给客户端,减少客户端的等待

总结

当然,HTTP/2 新增那么多的特性,是不是 HTTP 的语法要重新学呢?不需要,HTTP/2 完全兼容之前 HTTP 的语法和语义,如请求头、URI、状态码、头部字段都没有改变,完全不用担心。同时,在安全方面,HTTP 也支持 TLS,并且现在主流的浏览器都公开只支持加密的 HTTP/2, 因此你现在能看到的 HTTP/2 也基本上都是跑在TLS 上面的了。最后放一张分层图给大家参考:

HTTP/2 中的二进制帧是如何设计的?

帧结构

HTTP/2 中传输的帧结构如下图所示:

每个帧分为帧头帧体。先是三个字节的帧长度,这个长度表示的是帧体的长度。

然后是帧类型,大概可以分为数据帧控制帧两种。数据帧用来存放 HTTP 报文,控制帧用来管理的传输。

接下来的一个字节是帧标志,里面一共有 8 个标志位,常用的有 END_HEADERS表示头数据结束,END_STREAM表示单方向数据发送结束。

后 4 个字节是Stream ID, 也就是流标识符,有了它,接收方就能从乱序的二进制帧中选择出 ID 相同的帧,按顺序组装成请求/响应报文。

流的状态变化

从前面可以知道,在 HTTP/2 中,所谓的,其实就是二进制帧的双向传输的序列。那么在 HTTP/2 请求和响应的过程中,流的状态是如何变化的呢?
HTTP/2 其实也是借鉴了 TCP 状态变化的思想,根据帧的标志位来实现具体的状态改变。这里我们以一个普通的请求-响应过程为例来说明:


最开始两者都是空闲状态,当客户端发送Headers帧后,开始分配Stream ID, 此时客户端的流打开, 服务端接收之后服务端的流也打开,两端的流都打开之后,就可以互相传递数据帧和控制帧了。

当客户端要关闭时,向服务端发送END_STREAM帧,进入半关闭状态, 这个时候客户端只能接收数据,而不能发送数据

服务端收到这个END_STREAM帧后也进入半关闭状态,不过此时服务端的情况是只能发送数据,而不能接收数据。随后服务端也向客户端发送END_STREAM帧,表示数据发送完毕,双方进入关闭状态

如果下次要开启新的流,流 ID 需要自增,直到上限为止,到达上限后开一个新的 TCP 连接重头开始计数。由于流 ID 字段长度为 4 个字节,最高位又被保留,因此范围是 0 ~ 2的 31 次方,大约 21 亿个。

流的特性

刚刚谈到了流的状态变化过程,这里顺便就来总结一下流传输的特性:

  • 并发性。一个 HTTP/2 连接上可以同时发多个帧,这一点和 HTTP/1 不同。这也是实现多路复用的基础。
  • 自增性。流 ID 是不可重用的,而是会按顺序递增,达到上限之后又新开 TCP 连接从头开始。
  • 双向性。客户端和服务端都可以创建流,互不干扰,双方都可以作为发送方或者接收方
  • 可设置优先级。可以设置数据帧的优先级,让服务端先处理重要资源,优化用户体验。

自己搭的博客目的是什么?主要写的内容是?

爱折腾,搭建了一个美化版的博客,主要整合前端相关知识点

询问了大学里面图像处理课程

博客专栏里面有图像处理相关博文,问到了这个点

如何学习前端(或者说是如何学习计算机领域知识)

先学好本科基础知识,锻炼思维,然后经常逛一些博客网站,例如掘金,学习优秀的人是怎样学习的,看一些书籍,比如js红宝书。另外,常逛一些b站学习一些老师教授的课程。

对未来的职业规划

热爱前端,干到退休(苦笑)往架构方向发展,然后面试官提到了是否未来会参与算法、人工智能相关领域。我非常赞同,也提了nodejs目前比较火热,后续也会继续学习 koa、egg框架,总之,懂的越多,不懂得更多,一直学习~

有什么问题可以询问

了解部门主要业务工作,了解部门规模

结果

感受:问题能想起来的暂时这么多,一面体验还是不错的,又增加了一些知识,还能和面试官交流一下大学学习课程,挺好的。

最后

文章产出不易,还望各位小伙伴们支持一波!关于面经的话,长文在此不方便更新,最新版的会放到个人网站里(具体见下文↓)

博主整理一个前端笔记仓库,目前已获100+star,也在努力产出更加优质的前端博客(请毫不留情地猛戳→→)小狮子前端の笔记仓库

其次,为方便大家更好阅读仓库笔记,搭建了一个个人网站(访问超逸の博客),方便小伙伴阅读玩耍~

学如逆水行舟,不进则退

猜你喜欢

转载自blog.csdn.net/weixin_42429718/article/details/108270139