前端手写代码汇总

5,165 阅读35分钟

简介

面试还是不会手写题,被面试官各种手写题虐得体无完肤。别着急,本文总结了前端高频手写题,只要认真看完,我相信你一定会有所收获。

HTML

元素节点增删改查

创建元素、文本、注释、属性节点

document.createElement() // 元素节点
document.createTextNode() // 文本节点
document.createComment() // 注释节点
document.createAttribute() // 属性节点

元素添加子节点

// parentNode为父节点、newNode为新节点、existingNode为已存在的指定节点

parentNode.insertBefore(newNode, existingNode) // 在指定节点前添加一个新节点
parentNode.appendChild(newNode) // 在parentNode子节点列表尾部添加一个子节点

删除、替换子节点

element.removeChild(deleteNode) // 可从子节点列表中删除某个节点。如删除成功,此方法可返回被删除的节点,如失败,则返回 NULL。

element.replaceChild(newNode,oldNode) // 替换一个子节点

节点类型

element.nodeType

// 如果节点是一个元素节点,nodeType 属性返回 1。
// 如果节点是属性节点, nodeType 属性返回 2。
// 如果节点是一个文本节点,nodeType 属性返回 3。
// 如果节点是一个注释节点,nodeType 属性返回 8。

CSS

CSS常考的手写题一般是布局、视口元素的计算、轮播、图片懒加载、无限列表、DOM操作。

图片轮播

图片轮播的实现方案有很多,这里笔者分别用纯cssjs两种方案来实现。其主要原理都是固定可视区域大小,然后利滚动,将原本隐藏的图片滚动到可视区域,形成轮播效果。

CSS动画实现

CSS动画方案的原理就是将图片水平平铺在一个盒子中,盒子放在一个固定宽度的容器中,超出部分隐藏。然后再利用逐帧动画进行移动,每次移动一张图片的距离。

<div id="app">
  <section class="box">
    <img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4c7b4883c1704286a9365575a2a1c05f~tplv-k3u1fbpfcp-watermark.image?">

    <img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0acae783691649c9b36fa6bd64aa31d2~tplv-k3u1fbpfcp-watermark.image?">

    <img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9c266e0576fd4632bec39228d81534e4~tplv-k3u1fbpfcp-watermark.image?">

    <img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5e4b66351ea64b55a329f636c255a55b~tplv-k3u1fbpfcp-watermark.image?">
  </section>
</div>
#app {
  @count: 4; // 四张图
  @speed: 1s; // 每张图轮播1秒
  @width: 200px; // 每张图宽度

  width: @width;
  margin: 0 auto;
  overflow: hidden; // 超出隐藏
  .box {
    display: flex;
    animation: move @count * @speed steps(@count) infinite; // 逐帧动画 无限循环
    img {
      width: @width;
      height: 300px;
    }
  }

  @keyframes move {
    0% {
      transform: translate(0, 0);
    }
    100% {
      transform: translate(-1 * @count * @width, 0);
    }
  }
}

示例代码

这种方式优点是实现起来很简单,缺点就是不支持手动左右切换图片。

JS实现

JS实现方案的原理就是将图片水平平铺在一个盒子中,盒子开启相对定位,并把盒子放在一个固定宽度的容器中,超出部分隐藏。然后再利用定位可以移动的特性,利用定时器每次移动一张图片的距离。

<div id="app">
  <section id="box">
    <img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4c7b4883c1704286a9365575a2a1c05f~tplv-k3u1fbpfcp-watermark.image?">

    <img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0acae783691649c9b36fa6bd64aa31d2~tplv-k3u1fbpfcp-watermark.image?">

    <img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9c266e0576fd4632bec39228d81534e4~tplv-k3u1fbpfcp-watermark.image?">

    <img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5e4b66351ea64b55a329f636c255a55b~tplv-k3u1fbpfcp-watermark.image?">
  </section>

  <div class='btns'>
    <button onclick="prev()">prev</button>
    <button onclick="next()">next</button>
  </div>

  <div class='btns'>
    <button onclick="autoPlay()">autoPlay</button>
    <button onclick="stopAutoPlay()">stopAutoPlay</button>
  </div>
</div>
#app {
  width: 200px;
  margin: 0 auto;
  overflow: hidden;
  #box {
    display: flex;
    position: relative;
    img {
      width: 200px;
      height: 300px;
    }
  }

  .btns {
    text-align: center;
  }
}
const box = document.getElementById("box"); // 图片盒子
let timer = null // 定时器
const count = 4 // 总共四张图
let index = 0 // 初始下标

const nextFun = () => {
  // 超过图片个数就又从第1张开始
  if(index >= count-1) {
    box.style.left = '0px'
    index = 0
  } else {
    // 每次移动一张图片宽度的距离
    box.style.left = (++index * -200) + 'px'
  }
}

// 循环播放
const autoPlay = () => {
  timer = setInterval(nextFun, 1000);
}

// 关闭自动播放
const stopAutoPlay = () => {
  // 清除定时器
  clearInterval(timer);
}

// 前一张
const prev = () => {
  // 如果是第一张则不处理
  if(index <= 0) {
    return
  } else {
    // 否则右移一张图的距离
    box.style.left = (--index * -200) + 'px'
  }
}

// 后一张
const next = () => {
  // 如果是最后一张则不处理
  if(index >= count-1) {
    return
  } else {
    // 否则左移一张图距离
    box.style.left = (++index * -200) + 'px'
  }
}

// 掘金编辑器需要这样处理,不然找不到方法
window.autoPlay = autoPlay
window.stopAutoPlay = stopAutoPlay
window.prev = prev
window.next = next

示例代码

这种方式相较使用css动画的方式相对复杂一点,优点是支持手动左右切换图片。

布局

布局是CSS面试里面的一个重点,常见的布局有很多,比如水平居中垂直居中水平垂直居中等高布局单栏布局双栏布局三栏布局。下面我们详细看看。

水平居中

对于水平居中一般可以使用如下四种方式

  1. 对于行内元素我们可以在父元素上设置text-align:center;来实现。
  2. 对于定长块级元素我们可以使用margin: 0 auto;来实现。
  3. 我们可以在父元素上使用flex布局来实现。
  4. 我们可以在父元素上使用grid布局来实现。
<div class="div1">
  <span>行内元素水平居中</span>
</div>

<div class="div2">
  <span>行内元素水平居中</span>
  <div>块级元素水平居中</div>
</div>

<div class="div3">
  <span>行内元素水平居中</span>
  <div>块级元素水平居中</div>
</div>

<div class="div4">块级元素水平居中</div>
.div1 {
  text-align: center;
}

.div2 {
  display: flex;
  justify-content: center;
}

.div3 {
  display: grid;
  justify-content: center;
}

.div4 {
  width: 130px;
  margin: 0 auto;
}

效果如下

FireShot Capture 014 - 行内元素块级元素水平居中 - codepen.io.png

点击查看代码运行实例

垂直居中

对于垂直居中一般可以使用如下三种方式

  1. 我们可以在父元素上设置line-height等于height来实现。
  2. 我们可以在父元素上使用flex布局来实现。
  3. 我们可以在父元素上使用grid布局来实现。
  4. 我们可以在父元素上使用table布局来实现。
<div class="div1">
  <span>行内元素垂直居中</span>
<!-- <div>块级元素垂直居中</div> -->
</div>

<div class="div2">
  <span>行内元素垂直居中</span>
  <div>块级元素垂直居中</div>
</div>

<div class="div3">
  <span>行内元素垂直居中</span>
  <div>块级元素垂直居中</div>
</div>

<div class="div4">
  <span>行内元素垂直居中</span>
  <div>块级元素垂直居中</div>
</div>
.div1 {
  height: 100px;
  background: lightgreen;
  line-height: 100px;
}

.div2 {
  height: 100px;
  background: lightblue;
  display: flex;
  align-items: center;
}

.div3 {
  height: 100px;
  background: lightgreen;
  display: grid;
  align-content: center;
}

.div4 {
  height: 100px;
  background: lightblue;
  display: table-cell;
  vertical-align: middle;
}

效果如下

FireShot Capture 015 - 行内元素块级元素垂直居中 - codepen.io.png

点击查看代码运行实例

水平垂直同时居中

比如我们想实现如下水平垂直同时居中的效果

FireShot Capture 013 - 纯绝对定位实现水平垂直同时居中 - codepen.io.png

实现水平垂直同时居中我们可以使用绝对定位table布局flex布局grid布局来实现。

首先我们创建一个需要居中的盒子。

<div class="box"></div>

纯绝对定位

.box {
  position: absolute;
  width: 200px;
  height: 100px;
  background: red;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  margin: auto;
}

点击查看代码运行实例

绝对定位加负外边距

这种方式需要知道居中元素的具体宽高,不然负的margin没法设置。

.box {
  position: absolute;
  width: 200px;
  height: 100px;
  background: red;
  left: 50%;
  top: 50%;
  margin-left: -100px;
  margin-top: -50px;
}

点击查看代码运行实例

绝对定位加平移

这种平移的方式就不需要考虑居中盒子的具体宽高了。

.box {
  position: absolute;
  width: 200px;
  height: 100px;
  background: red;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

点击查看代码运行实例

使用flex实现

html,body {
  height: 100%; 
}

body {
  background: gray;
  display: flex;
  align-items: center;
  justify-content: center;
}

.box {
  width: 200px;
  height: 100px;
  background: red;
}

点击查看代码运行实例

使用grid实现

html,body {
  height: 100%; 
}

body {
  background: gray;
  display: grid;
/*   align-content: center;
  justify-content: center; */
  
  /* align-content和justify-content的简写 */
  place-content: center;
}

.box {
  width: 200px;
  height: 100px;
  background: red;
}

点击查看代码运行实例

使用table加外边距实现

使用table布局需要注意如下

  1. display: tablepadding会失效
  2. display: table-rowmargin、padding同时失效
  3. display: table-cellmargin会失效
<div class="box">
  <div class="child"></div>
</div>
.box {
  background: red;
  height: 300px;
  width: 600px;
  display: table-cell;
  vertical-align: middle;
}

.child {
  width: 200px;
  height: 200px;
  background: lightgreen;
  margin: 0 auto;
}

点击查看代码运行实例

等高布局

等高布局一般把网页垂直分成几部分,每一部分的高度是取这几个模块中最高的那个。效果如下

WX20220317-144325.png

最常见的场景就是我们看视频的时候,左边是视频播放窗口,右边是视频目录,两边的高度是一样的。

flex布局实现

<div class="wrap">
  <div class="left">left</div>
  <div class="content">content</div>
  <div class="right">right</div>
</div>
html,
body {
  height: 100%;
  margin: 0;
}

.wrap {
  display: flex;
  min-height: 100%;
}

.left {
  background: lightblue;
  flex-basis: 200px;
}

.content {
  background: lightpink;
  /* 这里的高度只是为了模拟内容多少 */
  /* height: 100px; */
  /* height: 1000px; */
  flex-grow: 1;
}

.right {
  flex-basis: 200px;
  background: lightgreen;
}

点击查看代码运行实例

grid布局实现

<div class="wrap">
  <div class="left">left</div>
  <div class="content">content</div>
  <div class="right">right</div>
</div>
html,
body {
  height: 100%;
  margin: 0;
}

.wrap {
  display: grid;
  min-height: 100%;
  grid-template-columns: 200px auto 200px;
}

.left {
  background: lightblue;
}

.content {
  background: lightpink;
  /* 这里的高度只是为了模拟内容多少 */
  /* height: 100px; */
  /* height: 1000px; */
}

.right {
  background: lightgreen;
}

点击查看代码运行实例

单栏布局

单栏布局我们常用在网页框架上,一般我们把网页分为 headercontentfooter三部分。

WX20220308-165744.png

在不同的项目我们可能对这三部分的样式需求有所差别,比如需要顶部固定、需要底部固定等等。

顶底部都不固定

比如想实现如下效果,footer在内容不足的时候吸附在窗口底部,当内容多的时候又可以被抵到窗口下面。

使用padding加负margin实现
<div class="wrap">
  <div class="header">header</div>
  <div class="content">content</div>
</div>
<div class="footer">footer</div>
html, body {
  height: 100%;
  margin: 0;
}

.wrap {
  min-height: 100%;
  padding-bottom: 50px;
  overflow: auto;
  box-sizing: border-box;
}

.header {
  height: 50px;
  background: lightblue;
}

.content {
  background: lightpink;
  /* 这里的高度只是为了模拟内容多少 */
  height: 100px; 
  /*  height: 1000px; */
}

.footer {
  height: 50px;
  background: lightgreen;
  margin-top: -50px;
}

点击查看代码运行实例

使用flex实现
<div class="wrap">
  <div class="header">header</div>
  <div class="content">content</div>
  <div class="footer">footer</div>
</div>
html, body {
  height: 100%;
  margin: 0;
}

.wrap {
  display: flex;
  flex-direction: column;
  min-height: 100%;
}

.header {
  height: 50px;
  background: lightblue;
}

.content {
  background: lightpink;
  /* 这里的高度只是为了模拟内容多少 */
  height: 100px;
  /* height: 1000px; */
  flex-grow: 1;
}

.footer {
  height: 50px;
  background: lightgreen;
}

点击查看代码运行实例

顶部固定

使用padding加负margin加fixed实现顶部固定布局
<div class="header">header</div>
<div class="wrap">
 <div class="content">content</div>
</div>
<div class="footer">footer</div>
html, body {
  height: 100%;
  margin: 0;
}

.header {
  height: 50px;
  background: lightblue;
  position: fixed;
  width: 100%;
}

.wrap {
  min-height: 100%;
  padding-bottom: 50px;
  overflow: auto;
  box-sizing: border-box;
}

.content {
  margin-top: 50px;
  background: lightpink;
  /* 这里的高度只是为了模拟内容多少 */
  height: 100px; 
  /* height: 1000px; */
}

.footer {
  height: 50px;
  background: lightgreen;
  margin-top: -50px;
}

点击查看代码运行实例

使用flex加fixed定位实现
<div class="wrap">
  <div class="header">header</div>
  <div class="content">content</div>
  <div class="footer">footer</div>
</div>
html, body {
  height: 100%;
  margin: 0;
}

.wrap {
  display: flex;
  min-height: 100%;
  flex-direction:column;
}

.header {
  height: 50px;
  background: lightblue;
  position: fixed;
  width: 100%;
}

.content {
  background: lightpink;
  /* 这里的高度只是为了模拟内容多少 */
  /* height: 100px; */
  height: 1000px;
  margin-top: 50px;
  flex-grow: 1;
}

.footer {
  height: 50px;
  background: lightgreen;
}

点击查看代码运行实例

底部固定

使用padding加负margin实现底部固定布局
<div class="wrap">
  <div class="header">header</div>
  <div class="content">content</div>
</div>
<div class="footer">footer</div>
html, body {
  height: 100%;
  margin: 0;
}

.wrap {
  height: 100%;
  padding-bottom: 50px;
  overflow: auto;
  box-sizing: border-box;
}

.header {
  height: 50px;
  background: lightblue;
}

.content {
  background: lightpink;
  height: 100px;
  height: 1000px;
}

.footer {
  height: 50px;
  background: lightgreen;
  margin-top: -50px;
}

点击查看代码运行实例

使用flex加fixed定位实现
<div class="wrap">
  <div class="header">header</div>
  <div class="content">content</div>
  <div class="footer">footer</div>
</div>
html, body {
  height: 100%;
  margin: 0;
}

.wrap {
  display: flex;
  min-height: 100%;
  flex-direction:column;
}

.header {
  height: 50px;
  background: lightblue;
}

.content {
  background: lightpink;
  /* 这里的高度只是为了模拟内容多少 */
  /* height: 100px; */
  height: 1000px;
  flex-grow: 1;
  margin-bottom: 50px;
}

.footer {
  height: 50px;
  background: lightgreen;
  position: fixed;
  width: 100%;
  bottom: 0;
}

点击查看代码运行实例

顶底部都固定

使用fixed实现顶底部固定布局
<div class="header">header</div>
<div class="content">content</div>
<div class="footer">footer</div>
html, body {
  height: 100%;
  margin: 0;
}

.header {
  height: 50px;
  background: lightblue;
  position: fixed;
  width: 100%;
}

.content {
  background: lightpink;
  padding-top: 50px;
  padding-bottom: 50px;
  /* height: 100px; */
  height: 1000px;
}

.footer {
  height: 50px;
  background: lightgreen;
  position: fixed;
  bottom: 0;
  width: 100%;
}

点击查看代码运行实例

使用flex加fixed定位实现
<div class="wrap">
  <div class="header">header</div>
  <div class="content">content</div>
  <div class="footer">footer</div>
</div>
html, body {
  height: 100%;
  margin: 0;
}

.wrap {
  display: flex;
  min-height: 100%;
  flex-direction:column;
}

.header {
  height: 50px;
  background: lightblue;
  position: fixed;
  width: 100%;
}

.content {
  background: lightpink;
  /* 这里的高度只是为了模拟内容多少 */
  /* height: 100px; */
  height: 1000px;
  flex-grow: 1;
  margin-bottom: 50px;
  margin-top: 50px;
}

.footer {
  height: 50px;
  background: lightgreen;
  position: fixed;
  width: 100%;
  bottom: 0;
}

点击查看代码运行实例

两栏布局

两栏布局就是一边固定,另外一边自适应,效果如下

WX20220310-160617.png

实现两栏布局的方法也有很多,笔者接下来介绍用的比较多的几种方式。

左 float,然后右 margin-left(右边自适应)

<div class="aside"></div>
<div class="main"></div>
div {
  height: 500px;
}

.aside {
  width: 300px;
  float: left;
  background: yellow;
}

.main {
  background: aqua;
  margin-left: 300px;
}

点击查看代码运行实例

右 float,然后右 margin-right(左边自适应)

<div class="aside"></div>
<div class="main"></div>
div {
  height: 500px;
}

.aside {
  width: 300px;
  float: right;
  background: yellow;
}

.main {
  background: aqua;
  margin-right: 300px;
}

点击查看代码运行实例

absolute定位加margin-left(右边自适应)

<div class="wrap">
  <div class="aside"></div>
  <div class="main"></div>
</div>
div {
  height: 500px;
}

.wrap {
  position: relative;
}

.aside {
  width: 300px;
  background: yellow;
  position: absolute;
}

.main {
  background: aqua;
  margin-left: 300px;
}

点击查看代码运行实例

absolute定位加margin-right(左边自适应)

<div class="wrap">
  <div class="aside"></div>
  <div class="main"></div>
</div>
div {
  height: 500px;
}

.wrap {
  position: relative;
}

.aside {
  width: 300px;
  background: yellow;
  position: absolute;
  right: 0;
}

.main {
  background: aqua;
  margin-right: 300px;
}

点击查看代码运行实例

使用flex实现

<div class="wrap">
  <div class="aside"></div>
  <div class="main"></div>
</div>
div {
  height: 500px;
}

.wrap {
  display: flex;
}

.aside {
  flex: 0 0 300px;
  background: yellow;
  
}

.main {
  background: aqua;
  flex: 1 1;
}

点击查看代码运行实例

使用grid实现

<div class="wrap">
  <div class="aside"></div>
  <div class="main"></div>
</div>
div {
  height: 500px;
}

.wrap {
  display: grid;
  grid-template-columns: 300px auto;
}

.aside {
  background: yellow;
  
}

.main {
  background: aqua;
}

点击查看代码运行实例

三栏布局

三栏布局就是两边固定,中间自适应布局,效果如下

WX20220310-170949.png

实现三栏布局的方法也有很多,笔者接下来介绍用的比较多的几种方式。

position + margin-left + margin-right实现三栏布局

<div class="left"></div>
<div class="middle"></div>
<div class="right"></div>
html,
body {
  margin: 0;
}

div {
  height: 500px;
}

.left {
  position: absolute;
  left: 0;
  top: 0;
  width: 200px;
  background: green;
}

.right {
  position: absolute;
  right: 0;
  top: 0;
  width: 200px;
  background: red;
}

.middle {
  margin-left: 200px;
  margin-right: 200px;
  background: lightpink;
}

点击查看代码运行实例

float + margin-left + margin-right实现三栏布局

<div class="left"></div>
<div class="right"></div>
<div class="middle"></div>
html,
body {
  margin: 0;
}

div {
  height: 500px;
}

.left {
  width: 200px;
  background: green;
  float: left;
}

.right {
  width: 200px;
  background: yellow;
  float: right;
}

.middle {
  margin-left: 200px;
  margin-right: 200px;
  background: lightpink;
}

点击查看代码运行实例

flex实现三栏布局

<div class="wrap">
  <div class="left"></div>
  <div class="middle"></div>
  <div class="right"></div>
</div>
html,
body {
  margin: 0;
}

div {
  height: 500px;
}

.wrap {
  display: flex;
}

.left {
  flex: 0 0 200px;
  background: green;
}

.right {
  flex: 0 0 200px;
  background: yellow;
}

.middle {
  background: lightpink;
  flex: 1 1;
}

点击查看代码运行实例

grid实现三栏布局

<div class="wrap">
  <div class="left"></div>
  <div class="middle"></div>
  <div class="right"></div>
</div>
html,
body {
  margin: 0;
}

div {
  height: 500px;
}

.wrap {
  display: grid;
  grid-template-columns: 200px auto 200px;
}

.left {
  background: green;
}

.right {
  background: yellow;
}

.middle {
  background: lightpink;
}

点击查看代码运行实例

圣杯布局

圣杯布局在项目中基本上不会再使用了,在面试中我们会经常碰到,所以需要了解。

主要用到了浮动和和相对定位。

<div class="container">
  <div class="content">中间内容</div>
  <div class="left">左侧区域</div>
  <div class="right">右侧区域</div>
</div>
div {
  height: 500px;
}

.container {
  padding: 0 200px 0 200px;
  border: 1px solid black;
}

.content {
  float: left;
  width: 100%;
  background: #f00;
}

.left {
  width: 200px;
  background: #0f0;
  float: left;
  margin-left: -100%;
  position: relative;
  left: -200px;
}

.right {
  width: 200px;
  background: #00f;
  float: left;
  margin-left: -200px;
  position: relative;
  right: -200px;
}

点击查看代码运行实例

双飞翼布局

双飞翼布局在项目中基本上不会再使用了,在面试中我们会经常碰到,所以需要了解。

主要用到了浮动。

<div class="main">
  <div class="content">content</div>
</div>
<div class="left">left</div>
<div class="right">right</div>
div {
  height: 500px;
}

.main {
  float: left;
  width: 100%;
  background: #f00;
}

.main .content {
  /* margin、padding这两种方式都可以 */
  
  /*   margin-left: 200px;
  margin-right: 300px; */
  padding-left: 200px;
  padding-right: 300px;
}

.left {
  width: 200px;
  background: #0f0;
  float: left;
  margin-left: -100%;
}

.right {
  width: 200px;
  background: #00f;
  float: left;
  margin-left: -200px;
}

点击查看代码运行实例

视口元素计算

对于视口计算,常见的就是获取滚动容器滚动的位置、判断元素是否在屏幕可视范围内、检测滚动容器是否已滚动到底部等等。

在讲解这些之前,一定要先看看笔者前面写的彻底弄懂元素样式、位置、大小相关计算有助于下面例子更好的理解。

获取滚动容器滚动的位置

对于滚动容器,我们要分为两种。第一种是window,第二种就是我们的dom元素

对于window的滚动(也就是滚动条在body上)计算有的方法是window.pageXOffset和window.pageYOffset

而对于dom元素(也就是滚动条在dom元素上)计算有的方法是el.scrollLeft和el.scrollTop。这两者是不能搞混的,所以在获取滚动的位置时一定要清楚当前的滚动容器到底是什么。

所以要获取滚动容器滚动的位置,我们需要考虑这两种情况,并做兼容处理。

const getScrollPosition = (el = window) => ({
  x: el.pageXOffset || el.scrollLeft,
  y: el.pageYOffset || el.scrollTop
});

示例代码

设置滚动容器滚动距离

设置滚动容器滚动距离,我们也要分为两种。第一种是window,第二种就是我们的dom元素

对于普通dom元素我们可以使用el.scrollTopel.scrollLeft属性来设置y轴x轴滚动的距离。我们还可以使用el.scrollTo(x, y)el.scrollBy(x, y)方法来设置x轴y轴的滚动的距离。

但是对于window,我们只能使用window.scrollTo(x, y)window.scrollBy(x, y)方法来设置x轴y轴的滚动的距离。

注意window.scrollTo(x, y)window.scrollBy(x, y)是有区别的,window.scrollTo(x, y)是滚动到指定位置。window.scrollBy(x, y)是相对当前位置,需要滚动的距离。

const setScrollPosition = (el = window) => {
  // 方式一,只适用于普通dom元素
  if(el != window) {
    // el.scrollTop = 200 // 垂直方向
    // el.scrollLeft = 200 // 水平方向
  }
  
  // 方式二,window和普通dom元素都适用
  // el.scrollTo(0, 100) // y轴移动到100px位置
  el.scrollBy(0, 100) // y轴每次移动100px
};

示例代码

el.scrollIntoView()

当然,除了上面说的,其实还有个更好的滚动方法,那就是el.scrollIntoView()。它可以将指定元素滚动到滚动容器的上、中、下位置,并能指定滚动方式。这个方法非常好用,兼容性也很好。目前笔者项目中涉及到滚动的需求基本都是使用该方法来解决的。

示例代码

更多细节大家可以自行查看el.scrollIntoView()文档

检测滚动容器是否已滚动到底部

对于滚动容器,我们要分为两种。第一种是window,第二种就是我们的dom元素

对于window的滚动(也就是滚动条在body上)计算有的方法是document.documentElement.clientHeight + window.pageYOffset >= document.documentElement.scrollHeight。也就是可视窗口高度加上在y轴上滚动距离是不是大于等于容器总高度。(document.documentElement获取的是html元素)

而对于dom元素(也就是滚动条在dom元素上)计算有的方法是el.clientHeight + el.scrollTop >= el.scrollHeight

这两者是不能搞混的,所以在获取滚动的位置时一定要清楚当前的滚动容器到底是什么。

const isScrollBottom = (el = window) => {
  if(el == window) {
    return document.documentElement.clientHeight + window.pageYOffset >=
  document.documentElement.scrollHeight
  } else {
    return el.clientHeight + el.scrollTop >= el.scrollHeight
  }
};

示例代码

判断元素是否全部在屏幕可视范围内

要判断元素是否在屏幕可视范围内需要用到两个api,第一个是获取元素相对于浏览器窗口的位置信息的getBoundingClientRect(),第二个就是获取当前浏览器可视窗口大小的window.innerWidth、window.innerHeight

const elementIsVisibleInViewport = (el) => {
  // 获取元素相对可视窗口位置
  const { top, left, bottom, right } = el.getBoundingClientRect();
  // 获取可视窗口
  const { innerHeight, innerWidth } = window;
  return top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth;
};

示例代码

判断元素是否在屏幕可视范围内

有了上面的基础,判断元素是否在屏幕可视范围内就很简单了。上下和左右两两组合符合条件就可以了。

const elementIsVisibleInViewport = (el) => {
  const { top, left, bottom, right } = el.getBoundingClientRect();
  const { innerHeight, innerWidth } = window;

  return (top >= 0 && top < innerHeight || bottom > 0 && bottom <= innerHeight) && (left >= 0 && left < innerWidth || right > 0 && right <= innerWidth)
};

示例代码

判断元素是否全部在滚动容器可视范围内

这个和上面屏幕可视范围例子有点像,但是还是有区别的。上面的例子可能会有元素是不在滚动容器可视范围内,但是是在屏幕可视范围内的。(这里可能需要好好理解)

比如上面的例子,我滚动8px,child1其实有8px是不在滚动容器的可视范围内,但是它是全部在屏幕可视范围内的。

image.png

好啦,要判断元素是否全部在滚动容器可视范围内,我们首先需要获取元素相对滚动容器的位置,以及自身大小。这里我们需要注意,offsetLeft, offsetTop是相对offsetParent计算的,而要成为offsetParent就需要有定位,如果没有没找到定位父元素就会相对body元素计算。所以我们需要给滚动容器一个position: relative;定位。

image.png

第二我们需要获取滚动容器的滚动距离以及滚动容器可视区域大小。

最后,我们再来判断元素是否全部在滚动容器可视范围内。这里我们需要要分几种情况来讨论了

  1. 滚动容器宽高要大于等于元素,不然你再怎么滚动元素都是没办法在滚动容器中全部展示的。
  2. 滚动容器滚动距离要小于等于元素相对滚动容器的距离。这样才能保证元素顶部和左边一定是在滚动容器里面的。接下来我们再来判断元素底部和右边是否也在滚动容器可视区域。
  3. 滚动容器滚动的距离加容器可视区域要大于等于元素相对滚动容器的距离加自身大小。这样就保证了元素底部和右边也是在滚动容器可视区域。

分析到了这里,我们的代码就很容易出来了。

const elementIsVisibleInViewport = (box, el) => {
  // 获取元素相对滚动容器的位置,以及自身大小
  const {offsetLeft, offsetTop, offsetWidth, offsetHeight} = el
  // 获取滚动容器的滚动距离以及滚动容器可视区域大小
  const {scrollLeft, scrollTop, clientWidth, clientHeight} = box
  
  const offsetBottom = offsetTop + offsetHeight // 元素底部距离滚动容器顶部距离
  const offsetRight = offsetLeft + offsetWidth // 元素右边距离滚动容器最左边距离
  
  // 1. 滚动容器宽高要比元素大
  const bigger = clientWidth >= offsetWidth && clientHeight >= offsetHeight
  // 2. 滚动容器滚动距离要小于等于元素相对滚动容器的距离
  const small = scrollTop <= offsetTop && scrollLeft <= offsetLeft
  // 3. 滚动容器滚动的距离加容器可视区域要大于等于元素相对滚动容器的距离加自身大小
  const countBigger = scrollTop + clientHeight >= offsetBottom && scrollLeft + clientWidth >= offsetRight;
  
  return bigger && small && countBigger;
};

示例代码

判断元素是否在滚动容器可视范围内

有了上面的基础,判断元素是否在滚动容器可视范围内就很简单了。上下和左右两两组合符合条件就可以了。

const elementIsVisibleInViewport = (box, el) => {
  // 获取元素相对滚动容器的位置
  const {offsetLeft, offsetTop, offsetWidth, offsetHeight} = el

  const {scrollLeft, scrollTop, clientWidth, clientHeight} = box

  const offsetBottom = offsetTop + offsetHeight // 元素底部距离滚动容器顶部距离
  const offsetRight = offsetLeft + offsetWidth // 元素右边距离滚动容器最左边距离

  // 1. y轴滚动距离小于元素顶部距离滚动容器顶部距离并且滚动距离加视口大于元素顶部距离滚动容器顶部距离,确保元素顶部在可视范围内
  const yTop = scrollTop <= offsetTop && scrollTop + clientHeight > offsetTop
  // 2. y轴滚动距离大于元素顶部距离滚动容器顶部距离并且滚动距离小于元素底部距离滚动容器顶部距离,确保元素底部在可视范围内
  const yBottom = scrollTop >= offsetTop && scrollTop < offsetBottom
  // 3. x轴滚动距离小于元素左部距离滚动容器左部距离并且滚动距离加视口大于元素左部距离滚动容器左部距离,确保元素左部在可视范围内
  const xLeft = scrollLeft <= offsetLeft && scrollLeft + clientWidth > offsetLeft
  // 4. x轴滚动距离大于元素左部距离滚动容器左部距离并且滚动距离小于元素右部距离滚动容器左部距离,确保元素右部在可视范围内
  const xRight = scrollLeft >= offsetLeft && scrollLeft < offsetRight
  
  // 两两组合满足即可
  return (yTop || yBottom) && (xLeft || xRight)
};

示例代码

IntersectionObserver()

上面那种方法虽然能够计算出元素是否在滚动容器,但需要自己手动去计算,并且会引起回流与重绘,性能相对来说较差。这里笔者再推荐一个较好的方法,能自动检测元素是否出现在滚动容器的可视区域。

// 1.获取滚动容器
const app = document.getElementById("app")
// 2.获取需要监听的元素
const child2 = document.getElementById("child2")

// 3.root就是滚动容器,这里我们是app(需要注意,默认是body)
// 被监听的元素出现在了滚动容器的可视区域就是触发回调
const observer = new IntersectionObserver((entries) => {
  console.log(entries)
}, {root: app});

// 4.开启监听
observer.observe(child2);

更多细节大家可以自行查看IntersectionObserver()文档

示例代码

无限列表

假设我们现在有这样一个需求,有一万个数据需要在页面渲染,都有哪些解决方案呢?第一种可以一次性将一万条数据全部渲染在页面上,这种方式肯定是不行的。第二种就是用到的时候再加载,也就是滚动加载。第三种就是利用requestAnimationFrame分批渲染。

对于第二种方案滚动加载,这就得用到我们前面说的,检测滚动容器是否已滚动到底部的方法了。

我们可以给滚动容器添加滚动事件监听,当监听到容器滚动到底部的时候,再去获取下一页数据。

// 监听滚动
app.addEventListener('scroll', () => {
  // 触底再次获取数据
  if(isScrollBottom(app)) {
    currentPage++
    getData()
  }
})

示例代码

或者不想在触底再获取数据,想更早一点获取数据也是可以的,只需要将isScrollBottom稍作修改就可以了。比如我们想在滚动容器滚动到距离底部还有100px的时候就提前获取获取可以这样修改。

const isScrollBottom = (el = window) => {
  if(el == window) {
    return document.documentElement.clientHeight + window.pageYOffset >=
  document.documentElement.scrollHeight - 100
  } else {
    return el.clientHeight + el.scrollTop >= el.scrollHeight - 100
  }
};

对于第三种方案,这种方案虽然看起来是一次性渲染,其实并不是,而是利用requestAnimationFrame分批渲染。

requestAnimationFrame会跟着屏幕刷新频率进行调用。对于60Hz的屏幕,即每16.7ms会调用一次,我们就可以每16.7ms更新一部分数据到页面上。

假如我们有十万条数据需要渲染,我们可以每16.7ms更新20条数据到页面。

setTimeout(() => {
  // 假如需要插入十万条数据
  const total = 100000;
  // 一次插入的数据
  const once = 20;
  // 插入数据需要的次数
  const loopCount = Math.ceil(total / once);
  let countOfRender = 0;
  const ul = document.querySelector("ul");
  // 添加数据的方法
  function add() {
    const fragment = document.createDocumentFragment();
    for (let i = 0; i < once; i++) {
      const li = document.createElement("li");
      li.innerText = countOfRender * once + i;
      fragment.appendChild(li);
    }
    ul.appendChild(fragment);
    countOfRender += 1;
    loop();
  }
  function loop() {
    if (countOfRender < loopCount) {
      window.requestAnimationFrame(add);
    }
  }
  loop();
}, 0);

示例代码

图片懒加载

图片懒加载有几种不同的做法,

对于无限个数的图片,我们可以使用上面滚动加载的方案,这个笔者就不再多说了。

对于有限个数的图片,我们还可以使用第二种方案。

比如我们有20张图,每屏显示一张图。我们可以将20张图全部展示出来,只不过每张图片都是用本地的一张预加载图来占位,并不是显示真正的图片。图片真实地址我们放到data-src的自定义属性上。只有等到图片快要到显示窗口了,才进行替换。

<img data-src="https://real1.jpg" src="/static/default.jpg" />
<img data-src="https://real2.jpg" src="/static/default.jpg" />
<img data-src="https://real3.jpg" src="/static/default.jpg" />
...

那怎么判断图片快要到显示窗口了呢?那就得用到上面判断元素是否在屏幕可视范围内和判断元素是否在滚动容器可视范围内啦。

对于滚动容器是window的,我们使用判断元素是否在屏幕可视范围内方法。对于滚动容器是dom元素的我们使用判断元素是否在滚动容器可视范围内方法。

const images = document.getElementsByTagName("img")

document.addEventListener('scroll', () => {
  Array.from(images).forEach(img => {
    if(elementIsVisibleInViewport(img)) {
      // 替换成真实地址
      // img.src = img.dataset.src // 获取自定义属性方式1
      img.src = img.getAttribute('data-src') // 获取自定义属性方式2
    }
  })
})

这里笔者以滚动容器是window为例,写了个小demo,感兴趣的小伙伴可以测试一下,看不出效果的可以把网络速度调慢一点。

示例代码

上拉加载

要实现上拉加载其实就是我们上面说的,核心就是检测滚动容器是否滚动到底部。

image.png

// 监听滚动
app.addEventListener('scroll', () => {
  // 触底再次获取数据
  if(isScrollBottom(app)) {
    console.log('数据加载中')
    currentPage++
    getData()
  }
})

下拉刷新

对于下拉刷新可能就稍微复杂一点了。这里笔者总结了主要分成三步:

  1. 监听原生touchstart事件,记录其初始位置的值,e.touches[0].pageY
  2. 监听原生touchmove事件,记录并计算当前滑动的位置值与初始位置值的差值,大于0表示向下拉动,并借助CSS3的translateY属性使容器元素跟随手势向下滑动对应的差值,同时也应设置一个允许滑动的最大值;
  3. 监听原生touchend事件,若此时元素滑动达到最大值,则触发callback,同时将translateY重设为0,元素回到初始位置。

举个例子:

<main>
  <p class="refreshText"></p>
  <ul id="refreshContainer">
    <li>111</li>
    <li>222</li>
    <li>333</li>
    <li>444</li>
    <li>555</li>
    ...
  </ul>
</main>

监听容器的touchstart事件,记录初始的值

var _element = document.getElementById('refreshContainer'),
    _refreshText = document.querySelector('.refreshText'),
    _startPos = 0,  // 初始的值
    _transitionHeight = 0; // 移动的距离

_element.addEventListener('touchstart', function(e) {
    _startPos = e.touches[0].pageY; // 记录初始位置
    _element.style.position = 'relative';
    _element.style.transition = 'transform 0s';
}, false);

监听容器的touchmove移动事件,记录滑动差值,并更新相应提示信息。

_element.addEventListener('touchmove', function(e) {
    // e.touches[0].pageY 当前位置
    _transitionHeight = e.touches[0].pageY - _startPos; // 记录差值

    if (_transitionHeight > 0 && _transitionHeight < 60) { 
        _refreshText.innerText = '下拉刷新'; 
        _element.style.transform = 'translateY('+_transitionHeight+'px)';

        if (_transitionHeight > 55) {
            _refreshText.innerText = '释放更新';
        }
    }                
}, false);

最后,就是监听touchend离开的事件,让容器元素回到初始位置。

_element.addEventListener('touchend', function(e) {
    _element.style.transition = 'transform 0.5s ease 1s';
    _element.style.transform = 'translateY(0px)';
    _refreshText.innerText = '更新中...';
    // todo...

}, false);

从上面可以看到,在下拉到松手的过程中,经历了三个阶段:

  1. 当前手势滑动位置与初始位置差值大于零时,提示正在进行下拉刷新操作。
  2. 下拉到一定值时,显示松手释放后的操作提示。
  3. 下拉到达设定最大值松手时,执行回调,提示正在进行更新操作。

JS

JS的手写题就比较广泛了,有手写方面的,这个考察的是你对原理掌握的程度了。有封装方面的,这个考察的是你对知识考虑的全部全面了。还有数组方面的,这个主要是考察你对api熟练程度了。

手写 instanceof

instanceof 的原理就是通过原型链一层一层查找,直到原型链的尽头Object.prototype.__proto__ == null

const instance_of_test = (leftVaule, rightVaule) => {
  let leftVaule = leftVaule.__proto__; // 取左表达式的__proto__值
  let rightProto = rightVaule.prototype; // 取右表达式的 prototype 值
  while (true) {
    if (leftVaule === null) {
      return false;
    }
    if (leftVaule === rightProto) {
      return true;
    }
    leftVaule = leftVaule.__proto__;
  }
};

这里涉及到了原型和原型链,如果对这部分还不熟悉的小伙伴可以看看笔者前面写的都2022年了你不会还没搞懂JS原型和继承吧

手写 new

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的[[Prototype]]指针被赋值为构造函数的 prototype 属性。
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
function _new(fun, ...args) {
  // 创建一个对象,并以构造函数的prototype为原型
  const obj = Object.create(fun.prototype为原型)
  // 将该对象作为this传递进构造函数,然后运行构造函数进行赋值
  const result = fn.apply(obj, args)
  // 如果构造函数有返回值,并且是对象,则返回该对象,否则为我们创建的对象
  return result instanceof Object ? result : obj
}

手写继承

继承一直是js面试过程中考察的一个重点,所以对于各种继承我们要烂熟于心。

原型继承

原型链继承主要是利用原型对所有实例共享的特性来实现继承的。该继承方式主要有如下特点

  1. 原型在包含有引用类型的数据时,会被所有的实例对象所共享,容易造成修改的混乱。
  2. 创建子类型的时候不能向超类型传递参数。
  3. 实例属于子类不属于父类。
// 子类
function Child(name, age) {
  this.name = name;
  this.age = age;
}

//原型对象
const Father = {
  colors: ["red", "blue"],
};

// 原型继承
Child.prototype = Father;
const child = new Child("randy", 26);

image.png

构造继承

使用借用构造函数继承这种方式是通过在子类型的函数中调用父类型的构造函数来实现的。该继承方式有如下特点

  1. 构造函数继承解决了不能向父类型传递参数的缺点。
  2. 但是它存在的一个问题就是无法实现函数方法的复用,就是父类方法在每个实例里面都会存在,相较于原型继承浪费了存储空间。
  3. 并且父类型原型上定义的方法子类型也没有办法访问到。
  4. 实例属于子类不属于父类。
// 父类
function Father(name, age) {
  this.name = name;
  this.age = age;
  this.fatherColors = ["green", "yellow"];
}

//子类
function Child(name, age, sex) {
  // 构造继承 可以传参 解决了不能向父类型传递参数的缺点
  Father.call(this, name, age);
  this.sex = sex;
}

const child = new Child("randy", 26, "male");

image.png

组合继承

组合继承是将原型链继承构造函数继承组合起来使用的一种方式。通过借用构造函数的方式来实现实例属性的继承,通过将子类型的原型设置为父类的实例来实现原型属性的继承。该继承方式有如下特点

  1. 这种方式解决了上面的两种模式单独使用时的问题。能向父类型传递参数,能获取父类原型上的属性和方法。
  2. 由于我们是以超类型的实例来作为子类型的原型,所以调用了两次超类的构造函数。
  3. 由于原型是父类实例,所以实例对象的原型中多了很多不必要的属性(实例中有父类的方法和属性,原型里面还有,都是重复的)。
  4. 实例对象既属于父类又属于子类。
// 父类
function Father(name, age) {
  this.name = name;
  this.age = age;
  this.fatherColors = ["green", "yellow"];
}

//子类
function Child(name, age, sex) {
  // 构造继承
  Father.call(this, name, age);
  this.colors = ["red", "blue"];
  this.sex = sex;
}

// 原型继承
Child.prototype = new Father();
Child.prototype.constructor = Child;

const child = new Child("randy", 26, "male");

image.png

寄生式继承

寄生式继承的思路是创建一个用于封装继承过程的函数,通过传入一个对象,然后创建一个新对象,该对象的原型是传入的对象。然后对该新对象进行扩展,最后返回这个新对象。这个扩展的过程就可以理解是一种继承。该继承方式有如下特点

  1. 这种继承的优点就是对一个简单对象实现继承。
  2. 没有办法实现函数的复用。
  3. 传入对象会被作为新对象的原型,会被所有的实例对象所共享,容易造成修改的混乱。
  4. 创建子类型的时候不能向超类型传递参数。
  5. 实例是父类的实例。
function CreateObj(obj) {
  // 把传进来的对象作为新创建对象的原型
  let newObj = Object.create(obj);
  // 简单的一些扩展
  newObj.colors = ["red", "blue"];

  return newObj;
}

function Father(name, age) {
  this.name = name;
  this.age = age;
  this.sayFather = function () {
    console.log("child sayFather", this.name, this.age);
  };
  this.fatherColors = ["green", "yellow"];
}

let child = CreateObj(new Father("randy", 24));

image.png

这个特别像Object.create()这个API。

寄生式组合继承

组合继承的缺点就是使用超类型的实例作为子类型的原型,导致添加了不必要的原型属性。寄生式组合继承的方式是使用父类型的原型的副本来作为子类型的原型,这样就避免了创建不必要的属性。该继承方式是组合继承的升级版,有如下特点

  1. 原型在包含有引用类型的数据时,会被所有的实例对象所共享,容易造成修改的混乱。
  2. 创建子类型的时候能向超类型传递参数。
  3. 实例对象不再臃肿,原型只包含父类的原型。
  4. 实例对象既属于父类又属于子类。
function CreateObj(obj) {
  // 把传进来的对象作为新创建对象的原型
  let newObj = Object.create(obj);
  // 简单的一些扩展
  newObj.say = function () {
    console.log("say");
  };

  return newObj;
}

// 父类
function Father(name, age) {
  this.name = name;
  this.age = age;
  this.fatherColors = ["green", "yellow"];
}

//子类
function Child(name, age) {
  // 构造继承
  Father.call(this, name, age);
  this.colors = ["red", "blue"];
}

// 将父类原型传递过去,创建一个新对象,不再是父类的实例对象
const obj = CreateObj(Father.prototype);
// 寄生式组合继承 使用新对象作为子类的原型
Child.prototype = obj;
Child.prototype.constructor = Child;
const child = new Child("randy", 26);

image.png

class extends 继承

除了使用ES5的继承方式,我们还可以使用ES6class来实现继承。

从上面class的介绍我们知道,只有方法才会被挂载到原型上,这是寄生式组合继承的升级版,除了有寄生式组合继承的优点外还解决了原型修改混乱的问题。这应该是最佳的继承方式了。

  1. 创建子类型的时候能向超类型传递参数。
  2. 实例既属于子类又属于父类。
  3. 实例对象不再臃肿,原型只包含父类的原型。
class Father {
  constructor(name, age) {
    // 构造函数里面的属性或方法都会在子类实例上
    this.name = name;
    this.age = age;
  }

  // 父类方法会被挂载到原型的原型上
  sayFather() {
    console.log("child sayFather", this.name, this.age);
  }
}

// 子类继承
class Child extends Father {
  _colors = ["blue", "red"];

  constructor(name, age, sex) {
    // 没参数
    // super();
    // 有参数
    super(name, age);
    this.sex = sex;
  }

  // 方法会挂载到原型上
  say() {
    console.log("child say", this.name, this.age, this.sex);
  }
}

const child = new Child("randy", 24, "male");

image.png

手写 js浅拷贝、深拷贝

js中数据类型分为基本数据类型和引用数据类型。深拷贝浅拷贝都是针对引用数据类型来说的。

浅拷贝

function shallowCopy(object) {
  // 只拷贝对象
  if (!object || typeof object !== "object") return;

  // 根据 object 的类型判断是新建一个数组还是对象
  let newObject = Array.isArray(object) ? [] : {};

  // 遍历 object,并且判断是 object 的属性才拷贝,不处理原型上的属性
  for (let key in object) {
    if (object.hasOwnProperty(key)) {
      newObject[key] = object[key];
    }
  }

  return newObject;
}

深拷贝

function deepCopy(object) {
  // 只拷贝对象
  if (!object || typeof object !== "object") return;

  // 根据 object 的类型判断是新建一个数组还是对象
  let newObject = Array.isArray(object) ? [] : {};

  // 遍历 object,并且判断是 object 的属性才拷贝,不处理原型上的属性
  for (let key in object) {
    if (object.hasOwnProperty(key)) {
      // 如果还是对象,则递归处理
      newObject[key] =
        typeof object[key] === "object"
          ? deepCopy(object[key])
          : object[key];
    }
  }

  return newObject;
}

除了我们手动实现浅拷贝和深拷贝外,js其实还提供了一个快捷方法来实现浅拷贝深拷贝。

比如常见的浅拷贝方法有对象的Object.assign()扩展运算符{...obj},数组的Array.concat()Array.slice()Array.from()扩展运算符[...arr]

比如常见的深拷贝方法有JSON.parse(JSON.stringfy(obj))

感兴趣的可以看看笔者前面写的都2022年了你不会还没搞懂JS赋值拷贝、浅拷贝、深拷贝吧

手写节流、防抖

所谓防抖,就是指单位时间内函数只执行一次,如果在单位时间内重复触发该事件,则会重新计算函数执行时间。

常见的使用场景是在一些点击请求的事件上,避免因为用户的多次点击向后端发送多次请求。

function debounce(func, delay) {
  let timer = null
  return function() {
    const context = this
    const args = arguments
    if(timer) clearTimeout(timer)
    const canCall = !timer
    timer = setTimeout(() => {
      timer = null
    }, delay)
    
    // if(canCall) func.apply(context, args)
    if(canCall) func.call(context, ...args)
  }
}

所谓节流,就是单位时间内不管触发多少次函数,只固定执行一次函数。 节流会降低函数的执行频率。

常见的使用场景是在一些 scroll 函数的事件监听上,通过事件节流来降低事件调用的频率。

function throttle(func, delay) {
  let timer = null;
  return function () {
    const context = this;
    const args = arguments;
    if (!timer) {
      timer = setTimeout(() => {
        timer = null;
        // func.apply(context, args);
        func.call(context, ...args);
      }, delay);
    }
  };
}

其实防抖节流还有很多个版本。防抖可以分为立即执行版本和非立即执行版本。节流可以有定时器版本和时间戳版本。感兴趣的可以看看笔者前面写的节流、防抖一套带走

手写call、apply、bind

call

Function.prototype.myCall = function (context = window) {
  // 判断调用对象,不是方法直接抛错
  if (typeof this !== "function") {
    console.error("type error");
  }
  // 获取参数,除去第一个
  let args = [...arguments].slice(1);
  let result = null;
  // 将调用该函数的方法设为对象的方法
  context.fn = this; // 这里的this就是调用myCall的方法
  // 调用函数获取结果
  result = context.fn(...args);
  // 将属性删除
  delete context.fn;
  // 返回结果
  return result;
};

apply

Function.prototype.myApply = function (context = window) {
  // 判断调用对象,不是方法直接抛错
  if (typeof this !== "function") {
    console.error("type error");
  }
  // 获取第二个参数
  let args = [...arguments][1];
  let result = null;
  // 将调用该函数的方法设为对象的方法
  context.fn = this; // 这里的this就是调用myCall的方法
  // 调用函数获取结果
  if (args) {
    result = context.fn(...args);
  } else {
    result = context.fn();
  }
  // 将属性删除
  delete context.fn;
  // 返回结果
  return result;
};

bind

Function.prototype.myBind = function (context) {
  // 判断调用对象是否为函数
  if (typeof this !== "function") {
    throw new TypeError("Error");
  }
  // 获取参数,除去第一个
  const args = [...arguments].slice(1);
  // 获取调用myBind的方法
  const fn = this;
  return function Fn() {
    // 根据调用方式,传入不同绑定值
    console.log(this);
    return fn.apply(
      // this instanceof Fn 考虑的是不是new调用的情况
      this instanceof Fn ? this : context,
      args.concat(...arguments)
    );
  };
};

call、apply、bind都是用来改变this指向的,如果对this指向还有困惑的小伙伴,可以看看笔者前面写的都2022年了你不会还没搞懂this吧

手写函数柯里化

函数柯里化指的是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。

function curry(fn, args) {
  // 获取函数需要的参数长度
  let length = fn.length;

  args = args || [];

  return function () {
    let subArgs = args.slice(0);

    // 拼接得到现有的所有参数
    for (let i = 0; i < arguments.length; i++) {
      subArgs.push(arguments[i]);
    }

    // 判断参数的长度是否已经满足函数所需参数的长度
    if (subArgs.length >= length) {
      // 如果满足,执行函数
      return fn.apply(this, subArgs);
    } else {
      // 如果不满足,递归返回科里化的函数,等待参数的传入
      return curry.call(this, fn, subArgs);
    }
  };
}

// es6 实现
function curry(fn, ...args) {
  return fn.length <= args.length
    ? fn(...args)
    : curry.bind(null, fn, ...args);
}

手写 Object.is

Object.is===很像,但是他们之间有两个区别。

  1. NaN在===中是不相等的,而在Object.is中是相等的
  2. +0和-0在===中是相等的,而在Object.is中是不相等的
Object.is = function (x, y) {
  if (x === y) {
    // 当前情况下,只有一种情况是特殊的,即 +0 -0
    // 如果 x !== 0,则返回true
    // 如果 x === 0,则需要判断+0和-0,则可以直接使用 1/+0 === Infinity 和 1/-0 === -Infinity来进行判断
    return x !== 0 || 1 / x === 1 / y;
  }

  // x !== y 的情况下,只需要判断是否为NaN,如果x!==x,则说明x是NaN,同理y也一样
  // x和y同时为NaN时,返回true
  return x !== x && y !== y;
};

手写 Promise

手写Promise涉及的知识点有点多,除了会问Promise的实现,可能还会问Promise的一些静态方法的实现,比如all、race、catch、finally等。还有可能会问到async await。感兴趣的可以看看笔者前面写的手写Promise(保姆级教程)

封装一个数据类型判断函数

function getType(value) {
  // 首先判断数据是 null 的情况,如果是拼上空字符串返回
  if (value === null) {
    return value + "";
  }

  // 判断数据是引用类型的情况,不是引用数据类型就直接用typeof
  if (typeof value === "object") {
    // 内置引用数据类型都可以使用Object.prototype.toString.call
    let valueClass = Object.prototype.toString.call(value);

    // 如果判断是Object,则是自定义引用数据类型
    // 再使用constructor.name进一步判断
    if (valueClass.includes("Object")) {
      return value.constructor.name.toLowerCase();
    } else {
      // 转成数组
      let type = valueClass.split(" ")[1].split("");

      // 去除末尾的 ]
      type.pop();

      // 转成字符串并转成小写
      return type.join("").toLowerCase();
    }
  } else {
    // 判断数据是基本数据类型的情况和函数的情况
    return typeof value;
  }
}

对js数据类型以及数据类型转换还不熟悉的同学可以看看笔者前面写的都2022年了你不会还没搞懂JS数据类型吧

封装通用的事件侦听器函数

事件有标准和非标准之分,所以在书写上也会有少许差异。比如事件监听方法、阻止事件冒泡方法、阻止默认事件方法、获取事件对象方法等等。所以需要一个通用的兼容方案,来保证方法的可用性。

const EventUtils = {
  // 添加事件
  addEvent: function(element, type, handler) {
    if (element.addEventListener) {
      element.addEventListener(type, handler, false);
    } else if (element.attachEvent) {
      element.attachEvent("on" + type, handler);
    } else {
      element["on" + type] = handler;
    }
  },

  // 移除事件
  removeEvent: function(element, type, handler) {
    if (element.removeEventListener) {
      element.removeEventListener(type, handler, false);
    } else if (element.detachEvent) {
      element.detachEvent("on" + type, handler);
    } else {
      element["on" + type] = null;
    }
  },

  // 获取事件目标
  getTarget: function(event) {
    return event.target || event.srcElement;
  },

  // 获取 event 对象的引用,取到事件的所有信息,确保随时能使用 event
  getEvent: function(event) {
    return event || window.event;
  },

  // 阻止事件(主要是事件冒泡,因为 IE 不支持事件捕获)
  stopPropagation: function(event) {
    if (event.stopPropagation) {
      event.stopPropagation();
    } else {
      event.cancelBubble = true;
    }
  },

  // 取消事件的默认行为
  preventDefault: function(event) {
    if (event.preventDefault) {
      event.preventDefault();
    } else {
      event.returnValue = false;
    }
  },
};

这里涉及到事件的处理,如果对js中事件还有疑问❓的可以看看笔者前面写的# js中关于事件的那些事一次性搞懂

封装一个发布订阅模式

class EventEmitter {
  constructor() {
    this.cache = {}
  }

  on(name, fn) {
    if (this.cache[name]) {
      this.cache[name].push(fn)
    } else {
      this.cache[name] = [fn]
    }
  }

  off(name, fn) {
    const tasks = this.cache[name]
    if (tasks) {
      const index = tasks.findIndex((f) => f === fn || f.callback === fn)
      if (index >= 0) {
        tasks.splice(index, 1)
      }
    }
  }

  emit(name, once = false) {
    if (this.cache[name]) {
      // 创建副本,如果回调函数内继续注册相同事件,会造成死循环
      const tasks = this.cache[name].slice()
      for (let fn of tasks) {
        fn();
      }
      if (once) {
        delete this.cache[name]
      }
    }
  }
}

封装一个sleep函数

封装sleep有两种方式

第一种是借助异步和定时器

const sleep = (milliseconds) => {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

// 使用 借助async await
await sleep(2000)

第二种是借助循环和Date

const sleep = (milliseconds) => {
  const now = +new Date();
  while (+new Date() - now < milliseconds) {}
};

sleep(2000)

封装一个延迟执行函数

function delay(func, seconds, ...args) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(func(...args));
    }, seconds);
  });
}

数组扁平化

数组扁平化是指将一个多维数组变为一个一维数组

const arr = [1, [2, [3, [4, 5]]], 6];
// => [1, 2, 3, 4, 5, 6]

方法一:使用es6新方法flat()

depth指定要提取嵌套数组的结构深度,默认值为 1。

const res1 = arr.flat(depth);

方法二:利用正则

转成字符串再替换中括号再转成数组,但数据类型都会变为字符串。

const res2 = JSON.stringify(arr).replace(/[|]/g, '').split(',');

方法三:JSON方法加正则

转成字符串再替换中括号再转成数组,全程使用JSON,保证了数据格式不变。

const res3 = JSON.parse('[' + JSON.stringify(arr).replace(/[|]/g, '') + ']');

方法四:使用reduce和concat

const flatten = arr => {
  return arr.reduce((pre, cur) => {
    return pre.concat(Array.isArray(cur) ? flatten(cur) : cur);
  }, [])
}
const res4 = flatten(arr);

方法五:函数递归

const res5 = [];
const fn = arr => {
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
      fn(arr[i]);
    } else {
      res5.push(arr[i]);
    }
  }
}
fn(arr);

数组去重

const arr = [1, 1, '1', 17, true, true, false, false, 'true', 'a', {}, {}];
// => [1, '1', 17, true, false, 'true', 'a', {}, {}]

方法一:利用Set

const res1 = Array.from(new Set(arr));

方法二:利用循环和indexOf、include

利用indexOf

const unique1 = arr => {
  const res = [];
  for (let i = 0; i < arr.length; i++) {
    if (res.indexOf(arr[i]) === -1) res.push(arr[i]);
  }
  return res;
}

利用include

const unique2 = arr => {
  const res = [];
  for (let i = 0; i < arr.length; i++) {
    if (!res.includes(arr[i])) res.push(arr[i]);
  }
  return res;
}

方法三:利用filter,并结合indexOf

利用filter,返回条件是true的元素组成的新数组。

const unique3 = arr => {
  return arr.filter((item, index) => {
    return arr.indexOf(item) === index;
  });
}

数组交集

由所有属于集合A且属于集合B的元素所组成的集合,叫做集合A与集合B的交集

方法一:利用循环和indexOf、include

利用indexOf

const intersection1 = (arr1, arr2) => {
  const res = [];
  for (let i = 0; i < arr1.length; i++) {
    if (arr2.indexOf(arr1[i]) !== -1) res.push(arr1[i]);
  }
  return res;
};

利用include

const intersection2 = (arr1, arr2) => {
  const res = [];
  for (let i = 0; i < arr1.length; i++) {
    if (arr2.includes(arr1[i])) res.push(arr1[i]);
  }
  return res;
};

方法二:利用filter并结合indexOf、include

利用filter,返回条件是true的元素组成的新数组。

const intersection3 = (arr1, arr2) => {
  return arr1.filter((item) => {
    return arr2.indexOf(item) > -1;
  });
};

或者

const intersection4 = (arr1, arr2) => {
  return arr1.filter((item) => {
    return arr2.includes(item);
  });
};

数组差集

差集:A和B是两个集合,则所有属于A且不属于B的元素构成的集合,叫做集合A和集合B的差集。

方法一:利用循环和indexOf、include

利用indexOf

const intersection1 = (arr1, arr2) => {
  const res = [];
  for (let i = 0; i < arr1.length; i++) {
    if (arr2.indexOf(arr1[i]) == -1) res.push(arr1[i]);
  }
  return res;
};

利用include

const intersection2 = (arr1, arr2) => {
  const res = [];
  for (let i = 0; i < arr1.length; i++) {
    if (!arr2.includes(arr1[i])) res.push(arr1[i]);
  }
  return res;
};

方法二:利用filter并结合indexOf、include

利用filter,返回条件是true的元素组成的新数组。

const intersection3 = (arr1, arr2) => {
  return arr1.filter((item) => {
    return arr2.indexOf(item) == -1;
  });
};

或者

const intersection4 = (arr1, arr2) => {
  return arr1.filter((item) => {
    return !arr2.includes(item);
  });
};

数组并集

给定两个集合A,B,把它们所有的元素合并在一起组成的集合,叫做集合A与集合B的并集

方法一:利用concat并结合Set

先将两个数组连成一个数组,然后去重

const res1 = Array.from(new Set(arr1.concat(arr2)));

方法二:利用循环和indexOf、include

利用indexOf

const union1 = (arr1, arr2) => {
  const res = [];
  for (let i = 0; i < arr1.length; i++) {
    if (arr2.indexOf(arr1[i]) == -1) res.push(arr1[i]);
  }
  return arr2.concat(res);
};

利用include

const union2 = (arr1, arr2) => {
  const res = [];
  for (let i = 0; i < arr1.length; i++) {
    if (!arr2.includes(arr1[i])) res.push(arr1[i]);
  }
  return arr2.concat(res);
};

方法二:利用filter并结合indexOf、include

利用filter,返回条件是true的元素组成的新数组。

const union3 = (arr1, arr2) => {
  const res = arr1.filter((item) => {
    return arr2.indexOf(item) == -1;
  });

  return arr2.concat(res);
};

或者

const union4 = (arr1, arr2) => {
  const res = arr1.filter((item) => {
    return arr2.indexOf(item) == -1;
  });

  return arr2.concat(res);
};

类数组转数组

一个拥有 length 属性和若干索引属性的对象就可以被称为类数组对象,类数组对象和数组类似,但是不能调用数组的方法。常见的类数组对象有 argumentsDOM 方法的返回结果。

对类数组的操作我们一般会将类数组转为数组。常见的类数组转换为数组的方法有这样几种:

方法一:通过 call 调用数组的 slice 方法来实现转换

Array.prototype.slice.call(arrayLike);

方法二:通过 call 调用数组的 splice 方法来实现转换

Array.prototype.splice.call(arrayLike, 0);

方法三:通过 apply 调用数组的 concat 方法来实现转换

Array.prototype.concat.apply([], arrayLike);

方法四:通过 Array.from 方法来实现转换

Array.from(arrayLike);

方法五:通过展开运算符

[...arguments]

列表转成树形结构

比如我们有这么一个列表,想把它转成一个树形结构

[
  {
    id: 1,
    text: "节点1",
    parentId: 0, //这里用0表示为根节点
  },
  {
    id: 2,
    text: "节点1_1",
    parentId: 1, // 通过这个字段来确定子父级
  },
  // ...
]

// 转成

[
  {
    id: 1,
    text: "节点1",
    parentId: 0,
    children: [
      {
        id: 2,
        text: "节点1_1",
        parentId: 1,
      },
    ],
  }
  // ...
];

实现代码如下:

function listToTree(data) {
  let temp = {};
  let treeData = [];
  // 放到临时对象,方便获取
  for (let i = 0; i < data.length; i++) {
    temp[data[i].id] = data[i];
  }
  // 遍历
  for (let i in temp) {
    // 不是根节点
    if (+temp[i].parentId != 0) {
      第一次添加就先初始化children数组
      if (!temp[temp[i].parentId].children) {
        temp[temp[i].parentId].children = [];
      }
      temp[temp[i].parentId].children.push(temp[i]);
    } else {
      // 根节点直接放到数组
      treeData.push(temp[i]);
    }
  }
  return treeData;
}

树形结构转成列表

比如我们有这么一个树形结构,想把它转成一个列表

[
  {
    id: 1,
    text: "节点1",
    parentId: 0,
    children: [
      {
        id: 2,
        text: "节点1_1",
        parentId: 1,
      },
    ],
  },
  // ...
]

// 转成

[
  {
    id: 1,
    text: "节点1",
    parentId: 0, //这里用0表示为根节点
  },
  {
    id: 2,
    text: "节点1_1",
    parentId: 1, //通过这个字段来确定子父级
  }
];

实现代码如下:

function treeToList(data) {
  let res = [];
  // 定义转换方法
  const tl = (tree) => {
    tree.forEach((item) => {
      if (item.children) {
        // 递归
        tl(item.children);
        delete item.children;
      }
      res.push(item);
    });
  };
  
  tl(data);
  return res;
}

后记

感谢小伙伴们的耐心观看,本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力!