Darrell

Darrell 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

Darrell 发布了文章 · 5月27日

聊聊苹果营销页中几个有趣的交互动画

前言

前两天在浏览 苹果 16寸 营销页面 的时候,发现了几个比较有意思的交互,心里想着自己虽然是一个穷逼,但是知识是无界限的呀,于是便研究了一波。

文章主要讲交互效果,所以文中会有很多 gif 图,大家最好连上无线再看,示例代码链接我放在了文章底部,有需要自取。

 

两个效果

翻盖效果

一个是屏幕慢慢打开的效果,在屏幕打开的过程中,电脑图片 是在屏幕中固定不动的,直到打开完毕或者关闭完毕的时候再让 电脑图片 随着滚动条滚动。

缩放图片

开始时是一张全屏的图片,在滚动过程中慢慢变成另一张图片,接着这张图片以屏幕正中间为基准点慢慢缩小,在缩小的过程中,这张图是定在屏幕中央的,缩小到一定值的时候,图片随着滚动条滚动。

 

前置知识

再动手写代码之前,我们需要了解几个在接下来代码中要用到的知识点。

粘性定位 sticky

可以简单的认为是 相对定位 relative固定定位 fixed 的混合,元素在跨越指定范围前为相对定位,之后为固定定位。

sticky 元素固定的相对偏移是相对于离它最近的具有滚动框的祖先元素,如果祖先元素都不可以滚动,那么是相对于 viewport 来计算元素的偏移量。

一个例子

如下代码,html 结构如下:

<body>
  <h1>我是 sticky 的第一个 demo</h1>
  <nav>
    <h3>导航A</h3>
    <h3>导航B</h3>
    <h3>导航C</h3>
  </nav>
  <article>
    <p>...</p>
    <p>...</p>
    // ...
  </article>
</body>  

样式如下:

nav {
  display: table;
  width: 100%;
  position: sticky;
  top: 0;
}

在代码中 nav 元素会根据 body 进行粘性定位,在 viewport 视口滚动到元素 top 距离小于 0px 之前,元素为相对定位,也就是说他会随着文档滚动。之后,元素将固定在与顶部距离 0px 的位置。

原理

sticky 原理大家可以看一下张鑫旭老师的 深入理解position sticky粘性定位的计算规则,可以先简单看一下老师讲解 sticky 时用的这个图:

图片引自 张鑫旭 深入理解position sticky

其中 <nav>sticky 元素,蓝色框区域是 sticky 的爸爸元素,用于承载 sticky 元素,红色区域是 <nav> 相对的可以滚动的元素。

  • 当整个蓝色区域在红色区域中的时候,sticky 元素是没有粘性效果的(如图一);
  • 当慢慢的向上滑的时候,蓝色的盒子超过了红色的滚动元素,那么 sticky 元素就会在蓝色的框中向下滑,实现粘性效果(如图二、三);
  • 当蓝色的盒子划出红色的盒子的时候,因为 sticky 元素在蓝色的框子中,所以也就直接被一波带走了,没有粘性效果(如图三)。

其实这样我们就可以很清楚的知道为什么 sticky 元素的高度为什么不能等于它爸爸的高度了,因为如果相等的话,粘性定位元素已经完全没有了实现粘性效果的空间,也就相当于失效了。

以上原理参考了张鑫旭老师的 深入理解position sticky粘性定位的计算规则,文章中有讲解 流盒粘性约束矩形 的概念解释,以及具体的代码结构和 css 实现,大家可以查看原文。

常用例子

在业务中我们可能会遇到这样一种场景:即一个列表,列表中的数据需要根据时间显示,而且时间需要在滚动的时候固定在最顶部,这个时候我们就可以使用 sticky 来解决这个问题:

具体 html 结构如下:

<body>
  <h1>时间固定demo</h1>
  <div className={styles.wrapper}>
    <section>
      <h4>5月20日</h4>
      <ul>
        <li>1</li>
        <li>2</li>
        <li>3</li>
        <li>4</li>
      </ul>
    </section>
    <section>
      <h4>5月19日</h4>
      <ul>
        <li>1</li>
        <li>2</li>
        <li>3</li>
      </ul>
    </section>
    // ...
</body>

样式如下:

body {
  margin: 0px;
  padding: 100px;
  height: 2000px;
}

h4 {
  margin: 2em 0 0;
  background-color: #333;
  color: #fff;
  padding: 10px;
  top: 0;
  z-index: 1;
  position: sticky;
}

代码如上,其中每一块是一个 <section>,然后给 <h4> 设置 sticky 定位,这样就可以实现上述效果了。

注意点

当然使用 sticky 的时候,我们需要注意几个点:

  • 父级元素不能有任何 overflow:visible 以外的 overflow 设置,否则没有粘滞效果。如果你设置的 sticky 没有效果,可以看看父级元素们有没有设置 overflow:hidden,去掉就可以了。
  • 必须指定 topbottomleftright 4 个值之一,否则只会处于相对定位。
  • 父元素的高度不能低于 sticky 元素的高度(参考上面原理解释)
  • sticky 元素仅在其父元素内生效(参考上面原理解释)

还有一个不得不提的就是兼容性,我们可以在 Can I use 官网看看 sticky 的兼容性,一片红:

IE 下完全是废了,如果你的项目需要考虑 IE 的话,你就需要使用 fixed 来兼容了。

 

滚动视差 background-attachment

什么是滚动视差,来看一下下面这个例子就明白了:

视差滚动(Parallax Scrolling)是指让多层背景以不同的速度移动,形成立体的运动效果,带来非常出色的视觉体验。

上图中的效果,我们只需要一行 css 就可以实现了,不需要写复杂的 js 代码,直接设置 background-attachment: fixed 就完成了。

html 结构

<body>
  <section className={`${styles.gImg} ${styles.gImg1}`}>IMG1</section>
  <section className={`${styles.gImg} ${styles.gImg2}`}>IMG2</section>
  <section className={`${styles.gImg} ${styles.gImg3}`}>IMG3</section>
</body>

样式代码

section {
  height: 100vh;
}

.gImg {
  background-attachment: fixed;
  background-size: cover;
  background-position: center center;
  width: 100%;
}

.gImg1 {
  background-image: url(@/assets/mac1.jpg);
}

.gImg2 {
  background-image: url(@/assets/mac2.jpg);
}

.gImg3 {
  background-image: url(@/assets/mac4.jpg);
}

通过滚动视差这个 css 我们基本上可以实现第二个动画了。

关于滚动视差的讲解,大家可以参考这篇文章 滚动视差?CSS 不在话下,写的很详细。

 

Canvas 画图

其实第二个动画我们也可以使用 canvas 画图来实现,我们可以在一块画布中画出两张图片,根据滚动的距离,去显示两张图片在画布中的比例。

可以通过 canvas 提供的 drawImage 方法来进行画图,这个方法提供了多种方式在 Canvas 上绘制图像。

比如我们需要实现的画出如下图:

其实我们就需要截取第一章图片的上半部分,下一张图片的下半部分,然后进行拼接就 ojbk 了,看看参数解释图:

这里我们需要传入 7 个参数,来实现我们需要的效果:

ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

具体参数的意思笔者也就不再这里细讲了,大家可以参考一下 drawImage() MDN 文档

思路大致是首先绘制第一张图片,作为底图,然后我们通过绘制第二张图片,覆盖掉部分第一张图片,这样就可以实现前面提到的效果。假设我们图片的原始宽高为 2048*1024,画布的大小为 544*341,在滚动的时候的偏移距离为 offsetTop,这样我们就可以写出如下代码:

function drawImage() {
  context.drawImage(img1, 0, 0, 2048, 1024, 0, 0, 544, 341);
  context.drawImage(img2, 0, 滚动偏移距离 * 1024 / 341, 2048, 1024, 0, 滚动偏移距离, 544, 341);
}
之前笔者使用过 ctx.drawImage(image, dx, dy, dWidth, dHeight),可以参考笔者写的 使用 React Hooks 实现仿石墨的图片预览插件,这次用到了 7 个参数,大家可以参考这篇文章 将图片画到canvas 上的几种方法,写的很详细。

 

transform 中的 matrix

CSS3 中使用 transform 可以对元素进行变换。其中包含:位移、旋转、偏移、缩放。 transform 可以使用 translate/rotate/skew/scale 的方式来控制元素变换,也可以使用 matrix 的方式来控制元素变换。

举个例子:

// 代码一
transform: matrix(1.5, 0, 0, 1.5, 0, 190.5);
// 代码二
transform: scale(1,5, 1.5) translate(0, 190.5)

上面两行代码的意思是一样的,之后我们做到第二个动画的时候会用到这个属性。

如果大家想要深入了解这个属性,可以参考:大学没学过数学也要理解 CSS3 transform 中的 matrix

 

开撸

初始化项目

工欲善其事,必先利其器。笔者使用 react Hooks 来完成这两个动画效果,并使用 umi 快速初始化一个项目,具体的初始化步骤可以参考笔者写的 dva理论到实践——帮你扫清dva的知识盲点,里面详细介绍了如何使用脚手架快速搭建一个项目。

搭建完成之后,笔者将之前讲到的例子都放在这里,方便大家点进去查看。

翻盖效果

翻盖效果其实很简单,你们绝对想不到,苹果营销页是怎么做的?

它用了 120 张图片,根据滚动距离来画出对应的在这个滚动位置上该展示的图片,对,你没有听错。我之前也以为应该是 css3 控制盖的角度从而实现翻盖效果的,是我想多了,哈哈哈。

思路

那样我们实现就很简单了,我们只需要做以下几点:

  • 首先要定义一个常量,规定从盖着到完全打开需要 滚动多少距离 来完成,我们这里定义为 400px
  • 我们需要知道什么时候开始进行翻盖或者合盖操作,这个可以让图片在屏幕正中间的时候,让其开始动画。
// 开始动画的 scrollTop
// $('#imgWrapper') 放图片的容器,html 结构下面有
startOpen = $('#imgWrapper').offset().top - (window.innerHeight / 2 - $('#imgWrapper').height() / 2);
  • 当翻盖或者合盖的时候,我们需要将电脑固定在视口中,等到完全翻开或者合上的时候,再让起随滚动条滚动,这里我们可以使用 position: sticky

html 结构

<body>
 // ...
 <div className={styles.stickyContainer}>
   <div className={styles.stickyWrapper}>
     <div id="imgWrapper" className={styles.imgWrapper}>
       <img
          data-original={require(`@/assets/${asset}.jpg`)}
          alt="图片1"
       />
     </div>
   </div>
 </div>
 // ...
</body>
其中动态引入图片我们可以通过 require(图片路径) 来完成,如上面的代码,我们只需要计算出对应滚动距离所需要展示的图片名字即可。

样式代码

.stickyContainer {
  height: 150vh;
}

.stickyWrapper {
  height: 100vh;
  position: sticky;
  top: 100px;
}

.imgWrapper {
  width: 100vh;
  height: 521px;
  margin: 0 auto;
}

.imgWrapper img {
  width: 100%;
}

接着就是在滚动的过程中计算出当先需要显示的图片是那一张,我们上面提到:120 张图片,在 400px 的滚动距离中完成动画。

首先我们再加载完成后可以得出,我们可以得出开始动画的距离文档顶部的滚动值 startOpen,因此我们可以得出如下代码:

useEffect(() => {
  // 绑定事件
  window.addEventListener('scroll', scrollEvent, false);

  // 开始动画的滚动距离
  // startOpen
  startOpen = $('#imgWrapper').offset().top - (window.innerHeight / 2 - $('#imgWrapper').height() / 2);

  return ()=>{
    window.removeEventListener('scroll', scrollEvent, false);
  }
}, []);

// 滚动事件
const scrollEvent = () => {
  // 实时的 scrollTop
  const scrollTop = $('html').scrollTop();
  let newAsset = ''

  if (scrollTop > startOpen && scrollTop < startOpen + 400) {
    let offset = Math.floor((scrollTop - startOpen) / 400 * 120);
    
    if (offset < 1) {
      offset = 1;
    } else if (offset > 120) {
      offset = 120;
    }

    if (offset < 10) {
      newAsset = `large_000${offset}`;
    } else if (offset < 100) {
      newAsset = `large_00${offset}`;
    } else {
      newAsset = `large_0${offset}`;
    }
  }

  // 边界值判断
  // ....
  
  // 设置图片 url
  setAsset(newAsset);
};

预览效果

这个翻盖动画很简单,120张图片换着来,实时渲染对应的图片,其实没有什么技术含量,大家也可以尝试一下用其他的方法实现一波。

 

缩放图片

缩放图片到屏幕这个动画我们可以用两个方式实现,一个是 滚动视差 实现,一个是 canvas 在滚动过程中实时渲染图片。

开始之前我们来看一下没有放大的之前图,如下:

它由两张图片组成,屏幕中显示的图片,他与 电脑外壳 的上间距是 18px,当放大了之后,图片与电脑外壳图片 的上边距应该是 18 * 放大比率

电脑外壳图片,如下:

接下来我们就开始介绍两种实现方式。

 

Canvas 实现

Canvas 实现是将屏幕中显示的这张图片由 Canvas 来画。

思路

其实这个动画有两部分组成,一个是 图片覆盖,一个是 图片缩小

  • 图片覆盖

使用 Canvas 来解决,使用 Canvas 实现我们需要使用 drawImage 方法将两张图片画到一张画布上。只需要通过滚动的距离,对应计算出具体某个时候画布应该画多少比例的第一张图,画多少比例的第二张图,就可以解决了。只需要知道什么时候开始图片覆盖。

  • 图片缩小

我们使用 transform: matrix 来实现,其中图片缩小是基于屏幕正中央的点进行缩放的。

我们根据滚动的距离相应的计算出相应放大比率和 translate 的值,如下图,实时改变 transform: matrix 的参数值就行了。

这里我们需要计算出几个临界点的值,比如最大/小的放大比率,最大/小偏移值,开始缩小的点等。

  • 在进行动画的时候,canvas 包裹容器应该是 sticky 定位在视口中的,直到动画结束,canvas 包裹容器才会随着滚动条滚动。
一些重要的值

这里我们需要知道几个值:

  • 定义的常量
// canvas 显示的图片宽度
const CANVAS_WIDTH = 544;

// canvas 显示的图片高度
const CANVAS_HEIGHT = 341;

// 动画持续的距离
const ZOOM_SCROLL_RANGE = 400;

// canvas 显示的图片 实际宽度
const IMG_NATURAL_WIDTH = 2048;

// canvas 显示的图片 实际高度
const IMG_NATURAL_HEIGHT = 1024;
  • 放大比率(curScale),用于 matrixscale

最小的放大比率为 1,即是自身。

最大的放大比率是屏幕的高度除以屏幕显示图片的比率,这里笔者将 canvas 画出来的图片宽高定位 544 * 341

const CANVAS_WIDTH = 544;
const CANVAS_HEIGHT = 341;

const scaleRadio = window.innerHeight / CANVAS_HEIGHT;
所以放大比率的区间应该是 1 ~ scaleRadio 之间。
  • 偏移距离(translate),用于 matrix 的 偏移值

最大的偏移距离,应该是当 curScale 为 1 的时候,包裹元素距离视口顶部的距离,我们的缩放一直都是基于屏幕正中央这个点来进行放大/缩小的,所以可以很简单的得出:

// 最大的 translate
let StartScale = 0;
StartScale = window.innerHeight / 2 - $('#img-wrapper').height() / 2;

最小的偏移距离,应该是在 curScalescaleRadio 时,包裹元素距离视口顶部的距离,这个时候,我们就需要用到之前提到的视屏图片到电脑外壳的 top = 18px 这个值了,因为图片进行了放大,所以最小的偏移距离应该为:

miniTranslate = - 18 * scaleRadio
所以偏移距离的区间应该是 miniTranslate ~ StartScale 之间。
  • 开始缩放操作的起始点(NewStartScale

其实很简单我们需要在第二章图片完全覆盖掉第一张的图片的时候就进行开始缩放,这个值可以通过 Canvas 包裹元素距离顶部文档的top值 加上 一屏的高度 就能计算出。

let NewStartScale = 0;

NewStartScale = $('#section-sticky-hero').offset().top + window.innerHeight;
核心代码

核心代码就是滚动时候的计算:

const scrollEvent = () => {
  // 当前的 scrollTop
  const scrollTop = $('html').scrollTop();
  // 放大比率 默认为最大
  let curScale = scaleRadio;
  // 偏移距离 默认为最小
  let translate = -scaleRadio * 18;

  // StartScale:最大的偏移距离
  // NewStartScale:开始缩放操作的起始点
  // 没有就 return
  if (!NewStartScale || !StartScale)  return;

  // 计算 当前的 curScale
  // (scaleRadio - 1) / ZOOM_SCROLL_RANGE): 每 1px 放大多少
  // scrollTop + scaleRadio * 18 - NewStartScale:当前滚动了多少
  curScale = scaleRadio - ((scaleRadio - 1) / ZOOM_SCROLL_RANGE) * (scrollTop + scaleRadio * 18 - NewStartScale);

  // 边界值处理
  if (curScale > scaleRadio) {
    curScale = scaleRadio;
  } else if (curScale < 1) {
    curScale = 1;
  }

  // 计算 当前的 translate
  // 从 scaleRadio * 18 开始
  // all = scaleRadio * 18 + StartScale
  // 滑动过程中不断相加
  translate = -scaleRadio * 18 + ((scrollTop + scaleRadio * 18 - NewStartScale) / ZOOM_SCROLL_RANGE * (scaleRadio * 18 + StartScale));

  // 边界值处理
  if (translate > StartScale) {
    translate = StartScale;
  } else if (translate < -scaleRadio * 18) {
    translate = - scaleRadio * 18;
  }

  // 使用 canvas 画图
  if (image1 && image2) {
    // 在图片覆盖阶段
    // curScale 还是最大的比率
    if (curScale === scaleRadio) {
      drawImage({
        img1: image1,
        img2: image2,
        secTop: CANVAS_HEIGHT * (scrollTop + 18 * scaleRadio - NewStartScale) / window.innerHeight,
      });
    } else {
      // 如果不是最大的比率,说明图片已经覆盖完了
      // 直接显示第二章
      drawImage({
        img1: image1,
        img2: image2,
        secTop: 0,
      });
    }
  }
    
  // 设置样式
  $('#img-wrapper').css({
    transform: `matrix(${curScale}, 0, 0, ${curScale}, 0, ${translate})`,
  });
};

html 结构如下:

<body>
  // ... 其他内容
  <div id="section-sticky-hero" className={styles.stickyContainer}>
    <div className={styles.componentContainer}>
      <div className={styles.imgWrapper} id="img-wrapper">
        <canvas ref={canvasRef} id="canvas" className={styles.canvas}></canvas>
      </div>
    </div>
  </div>
  // ... 其他内容
</body>
篇幅有限,笔者只列举了滚动事件的代码和 html 结构,其他的代码,比如 drawImage 这个方法,大家有兴趣的话,可以参考源码。
预览效果图

 

滚动视差实现

前面我们也讲了滚动视差的原理,有了这个 background-attachment: fixed 属性,第二个动画基本上已经实现一半了。

实现思路

和上面的 canvas 画图相比的话,其实就是图片覆盖的这一步不一样,其他基本上都是类似的,包括边界值的计算。

  • 图片覆盖

这里我们需要将两张图片都设置为背景图片,同时我们需要给第二张图片套上 电脑外壳图片

当第一张图片充满屏幕的时候,就给两张图片同时加上 background-attachment: fixed 属性,不能一开始的时候就加上这个属性,不然就会变成下面这个效果:

  • 图片缩小

这里我们不使用 transform: matrix 来做这个放大缩小,我们使用 background-positionbackground-size 来进行图片的 缩小/放大和偏移

  • 其他的都与 Canvas 实现的原理大同小异。
核心代码

滚动逻辑代码如下:

const CANVAS_WIDTH = 544;
const CANVAS_HEIGHT = 341;

const WRAPPER_WIDTH = 694;
const WRAPPER_HEIGHT = 408;

const ZOOM_SCROLL_RANGE = 400;

// scalaRadio
// 图片放大的最大的倍数
const scaleRadio = window.innerHeight / CANVAS_HEIGHT;

const scrollEvent = () => {
  const scrollTop = $('html').scrollTop();
  let curScale = scaleRadio;
  let translate = -scaleRadio * 18;

  if (!imgFixFixed || !StartScale) return;

  // 第一张图片的 距离文档的顶部的距离为 imgFixFixed
  // 第一章图片的高度为 100vh,即一屏的高度
  // 所以第二章图片的 scrollTop 为 imgFixFixed + window.innerHeight
  if (scrollTop > imgFixFixed && scrollTop < imgFixFixed + window.innerHeight) {
    // 设置 fixed 属性
    setFixImg(true);
  } else {
    setFixImg(false);
  }

  // 假设我们缩放的距离是 400
  // 那么我们可以计算出 每 1px 缩放的比例
  // 接着一这个比例乘以滚动的距离
  curScale = scaleRadio - ((scaleRadio - 1) / ZOOM_SCROLL_RANGE) * (scrollTop - imgFixFixed - window.innerHeight);

  // curScale 边界值处理
  // ...

  // 从 scaleRadio * 18 开始
  // all = scaleRadio * 18 + StartScale
  // 滑动过程中不断相加
  translate = -scaleRadio * 18 + ((scrollTop - imgFixFixed - window.innerHeight) / ZOOM_SCROLL_RANGE * (scaleRadio * 18 + StartScale));

  // translate 边界值处理
  // ...

  // 设置图片的 css 样式
  // 进行图片基于中心点的缩放
  $('#g-img2').css({
    "width": curScale * CANVAS_WIDTH,
    "height": curScale * CANVAS_HEIGHT,
    "margin-top": `${translate + 18 * curScale}px`,
  });

  $('#img-wrapper').css({
    "width": scaleRadio * WRAPPER_WIDTH,
    "height": scaleRadio * WRAPPER_HEIGHT,
    "background-size": `${curScale * WRAPPER_WIDTH}px ${curScale * WRAPPER_HEIGHT}px`,
    "background-position": `center ${translate}px`,
  });
};

html 结构如下:

<body>
  // ... 其他内容
  <section id="g-img" className={`${styles.gImg} ${styles.gImg1} ${fixImg ? styles.fixed : ''}`}>IMG1</section>

  <div className={styles.stickyContainer}>
    <div className={styles.componentContainer}>
      <div className={styles.imgWrapper} id="img-wrapper">
        <section id="g-img2" className={`${styles.gImg} ${styles.gImg2} ${fixImg ? styles.fixed : ''}`}>IMG2</section>
      </div>
    </div>
  </div>
  // ... 其他内容
</body>
预览效果图

 

总结

今天讲了两个苹果营销页面的动画,文章没什么难点,主要是对几个基础知识点的运用。粘性定位滚动视差Canvas 画图matrix 属性的使用 等等,希望对大家有所帮助。

实不相瞒,想要个赞!

 

参考资料

 

示例代码

查看原文

赞 2 收藏 0 评论 0

Darrell 发布了文章 · 5月15日

30分钟,带你实现一个符合规范的 Promise(巨详细)

前言

关于 Promise 原理解析的优秀文章,在掘金上已经有非常多了。但是笔者总是处在 看了就会,一写就废 的状态,这是笔者写这篇文章的目的,为了理一下 Promise 的编写思路,从零开始手写一波代码,同时也方便自己日后回顾。

 

Promise 的作用

PromiseJavaScript 异步编程的一种流行解决方案,它的出现是为了解决 回调地狱 的问题,让使用者可以通过链式的写法去编写写异步代码,具体的用法笔者就不介绍了,大家可以参考阮一峰老师的 ES6 Promise教程

 

课前知识

观察者模式

什么是观察者模式:

观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。

Promise 是基于 观察者的设计模式 实现的,then 函数要执行的函数会被塞入观察者数组中,当 Promise 状态变化的时候,就去执行观察组数组中的所有函数。

事件循环机制

实现 Promise 涉及到了 JavaScript 中的事件循环机制 EventLoop、以及宏任务和微任务的概念。

事件循环机制的流程图如下:

大家可以看一下这段代码:

console.log(1);

setTimeout(() => {
  console.log(2);
},0);

let a = new Promise((resolve) => {
  console.log(3);
  resolve();
}).then(() => {
  console.log(4);
}).then(() => {
  console.log(5);
});

console.log(6);

如果不能一下子说出输出结果,建议大家可以先查阅一下 事件循环 的相关资料,在掘金中有很多优秀的文章。

Promises/A+ 规范

Promises/A+ 是一个社区规范,如果你想写出一个规范的 Promise,我们就需要遵循这个标准。之后我们也会根据规范来完善我们自己编写的 Promise

 

Promise 核心知识点

在动手写 Promise 之前,我们先过一下几个重要的知识点。

executor

// 创建 Promise 对象 x1
// 并在 executor 函数中执行业务逻辑
function executor(resolve, reject){
  // 业务逻辑处理成功结果
  const value = ...;
  resolve(value);
  // 失败结果
  // const reason = ...;
  // reject(reason);
}

let x1 = new Promise(executor);

首先 Promise 是一个类,它接收一个执行函数 executor,它接收两个参数:resolvereject,这两个参数是 Promise 内部定义的两个函数,用来改变状态并执行对应回调函数。

因为 Promise 本身是不知道执行结果失败或者成功,它只是给异步操作提供了一个容器,实际上的控制权在使用者的手上,使用者可以调用上面两个参数告诉 Promise 结果是否成功,同时将业务逻辑处理结果(value/reason)作为参数传给 resolvereject 两个函数,执行回调。

三个状态

Promise 有三个状态:

  • pending:等待中
  • resolved:已成功
  • rejected:已失败

Promise 的状态改变只有两种可能:从 pending 变为 resolved 或者从 pending 变为 rejected,如下图(引自 Promise 迷你书):

引自 Promise 迷你书

而且需要注意的是一旦状态改变,状态不会再变了,接下来就一直是这个结果。也就是说当我们在 executor 函数中调用了 resolve 之后,之后调用 reject 就没有效果了,反之亦然。

// 并在 executor 函数中执行业务逻辑
function executor(resolve, reject){
  resolve(100);
  // 之后调用 resolve,reject 都是无效的,
  // 因为状态已经变为 resolved,不会再改变了
  reject(100);
}

let x1 = new Promise(executor);

then

每一个 promise 都一个 then 方法,这个是当 promise 返回结果之后,需要执行的回调函数,他有两个可选参数:

  • onFulfilled:成功的回调;
  • onRejected:失败的回调;

如下图(引自 Promise 迷你书):

引自 Promise 迷你书

// ...
let x1 = new Promise(executor);

// x1 延迟绑定回调函数 onResolve
function onResolved(value){
  console.log(value);
}

// x1 延迟绑定回调函数 onRejected
function onRejected(reason){
  console.log(reason);
}

x1.then(onResolved, onRejected);

 

手写 Promise 大致流程

在这里我们简单过一下手写一个 Promise 的大致流程:

executor 与三个状态

  • new Promise 时,需要传递一个 executor 执行器函数,在构造函数中,执行器函数立刻执行
  • executor 执行函数接受两个参数,分别是 resolvereject
  • Promise 只能从 pendingrejected, 或者从 pendingfulfilled
  • Promise 的状态一旦确认,状态就凝固了,不在改变

then 方法

  • 所有的 Promise 都有 then 方法,then 接收两个参数,分别是 Promise 成功的回调 onFulfilled,和失败的回调 onRejected
  • 如果调用 then 时,Promise 已经成功,则执行 onFulfilled,并将 Promise 的值作为参数传递进去;如果 Promise 已经失败,那么执行 onRejected,并将 Promise 失败的原因作为参数传递进去;如果 Promise 的状态是 pending,需要将 onFulfilledonRejected 函数存放起来,等待状态确定后,再依次将对应的函数执行(观察者模式)
  • then 的参数 onFulfilledonRejected 可以不传,Promise可以进行值穿透

链式调用并处理 then 返回值

  • Promise 可以 then 多次,Promisethen 方法返回一个新的 Promise
  • 如果 then 返回的是一个正常值,那么就会把这个结果(value)作为参数,传递给下一个 then 的成功的回调(onFulfilled
  • 如果 then 中抛出了异常,那么就会把这个异常(reason)作为参数,传递给下一个 then 的失败的回调(onRejected)
  • 如果 then 返回的是一个 promise 或者其他 thenable 对象,那么需要等这个 promise 执行完撑,promise 如果成功,就走下一个 then 的成功回调;如果失败,就走下一个 then 的失败回调。

上面是大致的实现流程,如果迷迷糊糊没关系,只要大致有一个印象即可,后续我们会一一讲到。

那接下来我们就开始实现一个最简单的例子开始讲解。

 

第一版(从一个简单例子开始)

我们先写一个简单版,这版暂不支持状态、链式调用,并且只支持调用一个 then 方法。

来个 🌰

let p1 = new MyPromise((resolve, reject) => {
    setTimeout(() => {
      resolved('成功了');
    }, 1000);
})

p1.then((data) => {
    console.log(data);
}, (err) => {
    console.log(err);
})

例子很简单,就是 1s 之后返回 成功了,并在 then 中输出。

实现

我们定义一个 MyPromise 类,接着我们在其中编写代码,具体代码如下:

class MyPromise {
  // ts 接口定义 ...
  constructor (executor: executor) {
    // 用于保存 resolve 的值
    this.value = null;
    // 用于保存 reject 的值
    this.reason = null;
    // 用于保存 then 的成功回调
    this.onFulfilled = null;
    // 用于保存 then 的失败回调
    this.onRejected = null;

    // executor 的 resolve 参数
    // 用于改变状态 并执行 then 中的成功回调
    let resolve = value => {
      this.value = value;
      this.onFulfilled && this.onFulfilled(this.value);
    }

    // executor 的 reject 参数
    // 用于改变状态 并执行 then 中的失败回调
    let reject = reason => {
      this.reason = reason;
      this.onRejected && this.onRejected(this.reason);
    }

    // 执行 executor 函数
    // 将我们上面定义的两个函数作为参数 传入
    // 有可能在 执行 executor 函数的时候会出错,所以需要 try catch 一下 
    try {
      executor(resolve, reject);
    } catch(err) {
      reject(err);
    }
  }

  // 定义 then 函数
  // 并且将 then 中的参数复制给 this.onFulfilled 和 this.onRejected
  private then(onFulfilled, onRejected) {
    this.onFulfilled = onFulfilled;
    this.onRejected = onRejected;
  }
}

好了,我们的第一版就完成了,是不是很简单。

不过这里需要注意的是,resolve 函数的执行时机需要在 then 方法将回调函数注册了之后,在 resolve 之后在去往赋值回调函数,其实已经完了,没有任何意义。

上面的例子没有问题,是因为 resolve(成功了) 是包在 setTimeout 中的,他会在下一个宏任务执行,这时回调函数已经注册了。

大家可以试试把 resolve(成功了)setTimeout 中拿出来,这个时候就会出现问题了。

存在问题

这一版实现很简单,还存在几个问题:

  • 未引入状态的概念

未引入状态的概念,现在状态可以随意变,不符合 Promise 状态只能从等待态变化的规则。

  • 不支持链式调用

正常情况下我们可以对 Promise 进行链式调用:

let p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolved('成功了');
  }, 1000);
})

p1.then(onResolved1, onRejected1).then(onResolved2, onRejected2)
  • 只支持一个回调函数,如果存在多个回调函数的话,后面的会覆盖前面的

在这个例子中,onResolved2 会覆盖 onResolved1onRejected2 会覆盖 onRejected1

let p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolved('成功了');
  }, 1000);
})

// 注册多个回调函数
p1.then(onResolved1, onRejected1);
p1.then(onResolved2, onRejected2);

接下来我们更进一步,把这些问题给解决掉。

 

第二版(实现链式调用)

这一版我们把状态的概念引入,同时实现链式调用的功能。

加上状态

上面我们说到 Promise 有三个状态:pendingresovledrejected,只能从 pending 转为 resovled 或者 rejected,而且当状态改变之后,状态就不能再改变了。

  • 我们定义一个属性 status:用于记录当前 Promise 的状态
  • 为了防止写错,我们把状态定义成常量 PENDINGRESOLVEDREJECTED
  • 同时我们将保存 then 的成功回调定义为一个数组:this.resolvedQueuesthis.rejectedQueues,我们可以把 then 中的回调函数都塞入对应的数组中,这样就能解决我们上面提到的第三个问题。
class MyPromise {
  private static PENDING = 'pending';
  private static RESOLVED = 'resolved';
  private static REJECTED = 'rejected';

  constructor (executor: executor) {
    this.status = MyPromise.PENDING;
    // ...

    // 用于保存 then 的成功回调数组
    this.resolvedQueues = [];
    // 用于保存 then 的失败回调数组
    this.rejectedQueues = [];

    let resolve = value => {
      // 当状态是 pending 是,将 promise 的状态改为成功态
      // 同时遍历执行 成功回调数组中的函数,将 value 传入
      if (this.status == MyPromise.PENDING) {
        this.value = value;
        this.status = MyPromise.RESOLVED;
        this.resolvedQueues.forEach(cb => cb(this.value))
      }
    }

    let reject = reason => {
      // 当状态是 pending 是,将 promise 的状态改为失败态
      // 同时遍历执行 失败回调数组中的函数,将 reason 传入
      if (this.status == MyPromise.PENDING) {
        this.reason = reason;
        this.status = MyPromise.REJECTED;
        this.rejectedQueues.forEach(cb => cb(this.reason))
      }
    }

    try {
      executor(resolve, reject);
    } catch(err) {
      reject(err);
    }
  }
}

完善 then 函数

接着我们来完善 then 中的方法,之前我们是直接将 then 的两个参数 onFulfilledonRejected,直接赋值给了 Promise 的用于保存成功、失败函数回调的实例属性。

现在我们需要将这两个属性塞入到两个数组中去:resolvedQueuesrejectedQueues

class MyPromise {
  // ...

  private then(onFulfilled, onRejected) {
    // 首先判断两个参数是否为函数类型,因为这两个参数是可选参数
    // 当参数不是函数类型时,需要创建一个函数赋值给对应的参数
    // 这也就实现了 透传
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason}

    // 当状态是等待态的时候,需要将两个参数塞入到对应的回调数组中
    // 当状态改变之后,在执行回调函数中的函数
    if (this.status === MyPromise.PENDING) {
      this.resolvedQueues.push(onFulfilled)
      this.rejectedQueues.push(onRejected)
    }

    // 状态是成功态,直接就调用 onFulfilled 函数
    if (this.status === MyPromise.RESOLVED) {
      onFulfilled(this.value)
    }

    // 状态是成功态,直接就调用 onRejected 函数
    if (this.status === MyPromise.REJECTED) {
      onRejected(this.reason)
    }
  }
}

then 函数的一些说明

  • 什么情况下 this.status 会是 pending 状态,什么情况下会是 resolved 状态

这个其实也和事件循环机制有关,如下代码:

// this.status 为 pending 状态
new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(1)
  }, 0)
}).then(value => {
  console.log(value)
})

// this.status 为 resolved 状态
new MyPromise((resolve, reject) => {
  resolve(1)
}).then(value => {
  console.log(value)
})
  • 什么是 透传

如下面代码,当 then 中没有传任何参数的时候,Promise 会使用内部默认的定义的方法,将结果传递给下一个 then

let p1 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolved('成功了');
  }, 1000);
})

p1.then().then((res) => {
  console.log(res);
})

因为我们现在还没支持链式调用,这段代码运行会出问题。

支持链式调用

支持链式调用,其实很简单,我们只需要给 then 函数最后返回 this 就行,这样就支持了链式调用:

class MyPromise {
  // ...
  private then(onFulfilled, onRejected) {
    // ...
    return this;
  }
}

每次调用 then 之后,我们都返回当前的这个 Promise 对象,因为 Promise 对象上是存在 then 方法的,这个时候我们就简单的实现了 Promise 的简单调用。

这个时候运行上面 透传 的测试代码了。

但是上面的代码还是存在相应的问题的,看下面代码:

const p1 = new MyPromise((resolved, rejected) => {
  resolved('resolved');  
});

p1.then((res) => {
  console.log(res);
  return 'then1';
})
.then((res) => {
  console.log(res);
  return 'then2';
})
.then((res) => {
  console.log(res);
  return 'then3';
})

// 预测输出:resolved -> then1 -> then2
// 实际输出:resolved -> resolved -> resolved

输出与我们的预期有偏差,因为我们 then 中返回的 this 代表了 p1,在 new MyPromise 之后,其实状态已经从 pending 态变为了 resolved 态,之后不会再变了,所以在 MyPromise 中的 this.value 值就一直是 resolved

这个时候我们就得看看关于 then 返回值的相关知识点了。

then 返回值

实际上 then 都会返回了一个新的 Promise 对象。

先看下面这段代码:

// 新创建一个 promise
const aPromise = new Promise(function (resolve) {
  resolve(100);
});

// then 返回的 promise
var thenPromise = aPromise.then(function (value) {
  console.log(value);
});

console.log(aPromise !== thenPromise); // => true

从上面的代码中我们可以得出 then 方法返回的 Promise 已经不再是最初的 Promise 了,如下图(引自 Promise 迷你书):

引自 Promise 迷你书

promise 的链式调用跟 jQuery 的链式调用是有区别的,jQuery 链式调用返回的对象还是最初那个 jQuery 对象;Promise 更类似于数组中一些方法,如 slice,每次进行操作之后,都会返回一个新的值。

改造代码

class MyPromise {
  // ...

  private then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => {throw reason}

    // then 方法返回一个新的 promise
    const promise2 = new MyPromise((resolve, reject) => {
      // 成功状态,直接 resolve
      if (this.status === MyPromise.RESOLVED) {
        // 将 onFulfilled 函数的返回值,resolve 出去
        let x = onFulfilled(this.value);
        resolve(x);
      }

      // 失败状态,直接 reject
      if (this.status === MyPromise.REJECTED) {
        // 将 onRejected 函数的返回值,reject 出去
        let x = onRejected(this.reason)
        reject && reject(x);
      }

      // 等待状态,将 onFulfilled,onRejected 塞入数组中,等待回调执行
      if (this.status === MyPromise.PENDING) {
        this.resolvedQueues.push((value) => {
          let x = onFulfilled(value);
          resolve(x);
        })
        this.rejectedQueues.push((reason) => {
          let x = onRejected(reason);
          reject && reject(x);
        })
      }
    });
    return promise2;
  }
}

// 输出结果 resolved -> then1 -> then2

存在问题

到这里我们就完成了简单的链式调用,但是只能支持同步的链式调用,如果我们需要在 then 方法中再去进行其他异步操作的话,上面的代码就 GG 了。

如下代码:

const p1 = new MyPromise((resolved, rejected) => {
  resolved('我 resolved 了');  
});

p1.then((res) => {
  console.log(res);
  return new MyPromise((resolved, rejected) => {
    setTimeout(() => {
      resolved('then1');
    }, 1000)
  });
})
.then((res) => {
  console.log(res);
  return new MyPromise((resolved, rejected) => {
    setTimeout(() => {
      resolved('then2');
    }, 1000)
  });
})
.then((res) => {
  console.log(res);
  return 'then3';
})

上面的代码会直接将 Promise 对象直接当作参数传给下一个 then 函数,而我们其实是想要将这个 Promise 的处理结果传递下去。

 

第三版(异步链式调用)

这一版我们来实现 promise 的异步链式调用。

思路

先看一下 thenonFulfilledonRejected 返回的值:

// 成功的函数返回
let x = onFulfilled(this.value);

// 失败的函数返回
let x = onRejected(this.reason);

从上面的的问题中可以看出,x 可以是一个 普通值,也可以是一个 Promise 对象,普通值的传递我们在 第二版 已经解决了,现在需要解决的是当 x 返回一个 Promise 对象的时候该怎么处理。

其实也很简单,当 x 是一个 Promise 对象的时候,我们需要进行等待,直到返回的 Promise 状态变化的时候,再去执行之后的 then 函数,代码如下:

class MyPromise {
  // ...

  private then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason}

    // then 方法返回一个新的 promise
    const promise2 = new MyPromise((resolve, reject) => {
      // 成功状态,直接 resolve
      if (this.status === MyPromise.RESOLVED) {
        // 将 onFulfilled 函数的返回值,resolve 出去
        let x = onFulfilled(this.value);
        resolvePromise(promise2, x, resolve, reject);
      }

      // 失败状态,直接 reject
      if (this.status === MyPromise.REJECTED) {
        // 将 onRejected 函数的返回值,reject 出去
        let x = onRejected(this.reason)
        resolvePromise(promise2, x, resolve, reject);
      }

      // 等待状态,将 onFulfilled,onRejected 塞入数组中,等待回调执行
      if (this.status === MyPromise.PENDING) {
        this.resolvedQueues.push(() => {
          let x = onFulfilled(this.value);
          resolvePromise(promise2, x, resolve, reject);
        })
        this.rejectedQueues.push(() => {
          let x = onRejected(this.reason);
          resolvePromise(promise2, x, resolve, reject);
        })
      }
    });
    return promise2;
  }
}

我们新写一个函数 resolvePromise,这个函数是用来处理异步链式调用的核心方法,他会去判断 x 返回值是不是 Promise 对象,如果是的话,就直到 Promise 返回成功之后在再改变状态,如果是普通值的话,就直接将这个值 resovle 出去:

const resolvePromise = (promise2, x, resolve, reject) => {
  if (x instanceof MyPromise) {
    const then = x.then;
    if (x.status == MyPromise.PENDING) {
      then.call(x, y => {
        resolvePromise(promise2, y, resolve, reject);
      }, err => {
        reject(err);
      })
    } else {
      x.then(resolve, reject);
    }
  } else {
    resolve(x);
  }
}

代码说明

resolvePromise

resolvePromise 接受四个参数:

  • promise2then 中返回的 promise
  • xthen 的两个参数 onFulfilled 或者 onRejected 的返回值,类型不确定,有可能是普通值,有可能是 thenable 对象;
  • resolverejectpromise2 的。

then 返回值类型

xPromise 的时,并且他的状态是 Pending 状态,如果 x 执行成功,那么就去递归调用 resolvePromise 这个函数,将 x 执行结果作为 resolvePromise 第二个参数传入;

如果执行失败,则直接调用 promise2reject 方法。

 

到这里我们基本上一个完整的 promise,接下来我们需要根据 Promises/A+ 来规范一下我们的 Promise

 

规范 Promise

前几版的代码笔者基本上是按照规范来的,这里主要讲几个没有符合规范的点。

规范 then(规范 2.2)

thenonFulfilledonRejected 需要异步执行,即放到异步任务中去执行(规范 2.2.4)

实现

我们需要将 then 中的函数通过 setTimeout 包裹起来,放到一个宏任务中去,这里涉及了 jsEventLoop,大家可以去看看相应的文章,如下:

class MyPromise {
  // ...

  private then(onFulfilled, onRejected) {
    // ...
    // then 方法返回一个新的 promise
    const promise2 = new MyPromise((resolve, reject) => {
      // 成功状态,直接 resolve
      if (this.status === MyPromise.RESOLVED) {
        // 将 onFulfilled 函数的返回值,resolve 出去
        setTimeout(() => {
          try {
            let x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch(err) {
            reject(err);
          }
        })
      }

      // 失败状态,直接 reject
      if (this.status === MyPromise.REJECTED) {
        // 将 onRejected 函数的返回值,reject 出去
        setTimeout(() => {
          try {
            let x = onRejected(this.reason)
            resolvePromise(promise2, x, resolve, reject);
          } catch(err) {
            reject(err);
          }
        })
      }

      // 等待状态,将 onFulfilled,onRejected 塞入数组中,等待回调执行
      if (this.status === MyPromise.PENDING) {
        this.resolvedQueues.push(() => {
          setTimeout(() => {
            try {
              let x = onFulfilled(this.value);
              resolvePromise(promise2, x, resolve, reject);
            } catch(err) {
              reject(err);
            }
          })
        })
        this.rejectedQueues.push(() => {
          setTimeout(() => {
            try {
              let x = onRejected(this.reason)
              resolvePromise(promise2, x, resolve, reject);
            } catch(err) {
              reject(err);
            }
          })
        })
      }
    });
    return promise2;
  }
}

使用微任务包裹

但这样还是有一个问题,我们知道其实 Promise.then 是属于微任务的,现在当使用 setTimeout 包裹之后,就相当于会变成一个宏任务,可以看下面这一个例子:

var p1 = new MyPromise((resolved, rejected) => {
  resolved('resolved');
})

setTimeout(() => {
  console.log('---setTimeout---');
}, 0);

p1.then(res => {
  console.log('---then---');
})

// 正常 Promise:then -> setTimeout
// 我们的 Promise:setTimeout -> then

输出顺序不一样,原因是因为现在的 Promise 是通过 setTimeout 宏任务包裹的。

我们可以改进一下,使用微任务来包裹 onFulfilledonRejected,常用的微任务有 process.nextTickMutationObserverpostMessage 等,我们这个使用 postMessage 改写一下:

// ...
if (this.status === MyPromise.RESOLVED) {
  // 将 onFulfilled 函数的返回值,resolve 出去
  // 注册一个 message 事件
  window.addEventListener('message', event => {
    const { type, data } =  event.data;

    if (type === '__promise') {
      try {
        let x = onFulfilled(that.value);
        resolvePromise(promise2, x, resolve, reject);
      } catch(err) {
        reject(err);
      }
    }
  });
  // 立马执行
  window.postMessage({
    type: '__promise',
  }, "http://localhost:3001");
}

// ...

实现方法很简单,我们监听windowmessage 事件,并在之后立马触发一个 postMessage 事件,这个时候其实 then 中的回调函数已经在微任务队列中了,我们重新运行一下例子,可以看到输出的顺序变为了 then -> setTimeout

当然 Promise 内部实现肯定没有这么简单,笔者在这里只是提供一种思路,大家有兴趣可以去研究一波。

规范 resolvePromise 函数(规范 2.3)

重复引用

重复引用,当 xpromise2 是一样的,那就需要报一个错误,重复应用。(规范 2.3.1)



因为自己等待自己完成是永远都不会有结果的。
const p1 = new MyPromise((resolved, rejected) => {
  resolved('我 resolved 了');  
});

const p2 = p1.then((res) => {
  return p2;
});

x 的类型

大致分为一下这么几条:

  • 2.3.2:当 x 是一个 Promise,那么就等待 x 改变状态之后,才算完成或者失败(这个也属于 2.3.3,因为 Promise 其实也是一个 thenable 对象)
  • 2.3.3:当 x 是一个对象 或者 函数的时候,即 thenable 对象,那就那 x.then 作为 then
  • 2.3.4:当 x 不是一个对象,或者函数的时候,直接将 x 作为参数 resolve 返回。

我们主要看一下 2.3.3 就行,因为 Prmise 也属于 thenable 对象,那什么是 thenable 对象呢?

简单来说就是具有 then方法的对象/函数,所有的 Promise 对象都是 thenable 对象,但并非所有的 thenable 对象并非是 Promise 对象。如下:

let thenable = {
 then: function(resolve, reject) {
   resolve(100);
 }
}

根据 x 的类型进行处理:

  • 如果 x 不是 thenable 对象,直接调用 Promise2resolve,将 x 作为成功的结果;
  • xthenable 对象,会调用 xthen 方法,成功后再去调用 resolvePromise 函数,并将执行结果 y 作为新的 x 传入 resolvePromise,直到这个 x 值不再是一个 thenable 对象为止;如果失败则直接调用 promise2reject
if (x != null && (typeof x === 'object' || typeof x === 'function')) {
  if (typeof then === 'function') {
    then.call(x, (y) => {
      resolvePromise(promise2, y, resolve, reject);
    }, (err) => {
      reject(err);
    })
  }
} else {
  resolve(x);
}

只调用一次

规范(Promise/A+ 2.3.3.3.3)规定如果同时调用 resolvePromiserejectPromise,或者对同一参数进行了多次调用,则第一个调用优先,而所有其他调用均被忽略,确保只执行一次改变状态。

我们在外面定义了一个 called 占位符,为了获得 then 函数有没有执行过相应的改变状态的函数,执行过了之后,就不再去执行了,主要就是为了满足规范。

x 为 Promise 对象

如果 xPromise 对象的话,其实当执行了resolve 函数 之后,就不会再执行 reject 函数了,是直接在当前这个 Promise 对象就结束掉了。

x 为 thenable 对象

x 是普通的 thenable 函数的时候,他就有可能同时执行 resolvereject 函数,即可以同时执行 promise2resolve 函数 和 reject 函数,但是其实 promise2 在状态改变了之后,也不会再改变相应的值了。其实也没有什么问题,如下代码:

// thenable 对像
{
 then: function(resolve, reject) {
   setTimeout(() => {
     resolve('我是thenable对像的 resolve');
     reject('我是thenable对像的 reject')
    })
 }
}

完整的 resolvePromise

完整的 resolvePromise 函数如下:

const resolvePromise = (promise2, x, resolve, reject) => {
  if(x === promise2){
    return reject(new TypeError('Chaining cycle detected for promise'));
  }
  let called;
  if (x != null && (typeof x === 'object' || typeof x === 'function')) {
    try {
      let then = x.then;
      if (typeof then === 'function') {
        then.call(x, y => {
          if(called)return;
          called = true;
          resolvePromise(promise2, y, resolve, reject);
        }, err => {
          if(called)return;
          called = true;
          reject(err);
        })
      } else {
        resolve(x);
      }
    } catch (e) {
      if(called)return;
      called = true;
      reject(e); 
    }
  } else {
    resolve(x);
  }
}

到这里就大功告成了,开不开心,兴不兴奋!

最后我们可以通过测试脚本跑一下我们的 MyPromise 是否符合规范。

测试

有专门的测试脚本(promises-aplus-tests)可以帮助我们测试所编写的代码是否符合 Promise/A+ 的规范。

但是貌似只能测试 js 文件,所以笔者就将 ts 文件转化为了 js 文件,进行测试

在代码里面加上:

// 执行测试用例需要用到的代码
MyPromise.deferred = function() {
  let defer = {};
  defer.promise = new MyPromise((resolve, reject) => {
      defer.resolve = resolve;
      defer.reject = reject;
  });
  return defer;
}

需要提前安装一下测试插件:

# 安装测试脚本
npm i -g promises-aplus-tests

# 开始测试
promises-aplus-tests MyPromise.js

结果如下:

完美通过,接下去我们就可以看看 Promise 更多方法的实现了。

 

更多方法

实现上面的 Promise 之后,其实编写其实例和静态方法,相对来说就简单了很多。

实例方法

Promise.prototype.catch

实现

其实这个方法就是 then 方法的语法糖,只需要给 then 传递 onRejected 参数就 ok 了。

private catch(onRejected) {
  return this.then(null, onRejected);
}
例子:
const p1 = new MyPromise((resolved, rejected) => {
  resolved('resolved');
})

p1.then((res) => {
  return new MyPromise((resolved, rejected) => {
    setTimeout(() => {
      rejected('错误了');
    }, 1000)
  });
})
.then((res) => {
  return new MyPromise((resolved, rejected) => {
    setTimeout(() => {
      resolved('then2');
    }, 1000)
  });
})
.then((res) => {
  return 'then3';
}).catch(error => {
  console.log('----error', error);
})

// 1s 之后输出:----error 错误了

Promise.prototype.finally

实现

finally() 方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。

private finally (fn) {
  return this.then(fn, fn);
}
例子
const p1 = new MyPromise((resolved, rejected) => {
  resolved('resolved');
})

p1.then((res) => {
  return new MyPromise((resolved, rejected) => {
    setTimeout(() => {
      rejected('错误了');
    }, 1000)
  });
})
.then((res) => {
  return new MyPromise((resolved, rejected) => {
    setTimeout(() => {
      resolved('then2');
    }, 1000)
  });
})
.then((res) => {
  return 'then3';
}).catch(error => {
  console.log('---error', error);
  return `catch-${error}`
}).finally(res => {
  console.log('---finally---', res);
})

// 输出结果:---error 错误了" -> ""---finally--- catch-错误了

 

静态方法

Promise.resolve

实现

有时需要将现有对象转为 Promise 对象,Promise.resolve()方法就起到这个作用。

static resolve = (val) => {
  return new MyPromise((resolve,reject) => {
    resolve(val);
  });
}
例子
MyPromise.resolve({name: 'darrell', sex: 'boy' }).then((res) => {
  console.log(res);
}).catch((error) => {
  console.log(error);
});

// 输出结果:{name: "darrell", sex: "boy"}

Promise.reject

实现

Promise.reject(reason) 方法也会返回一个新的 Promise 实例,该实例的状态为 rejected

static reject = (val) => {
  return new MyPromise((resolve,reject) => {
    reject(val)
  });
}
例子
MyPromise.reject("出错了").then((res) => {
  console.log(res);
}).catch((error) => {
  console.log(error);
});

// 输出结果:出错了

Promise.all

Promise.all() 方法用于将多个 Promise 实例,包装成一个新的 Promise 实例,

const p = Promise.all([p1, p2, p3]);
  • 只有 p1p2p3 的状态都变成 fulfilledp 的状态才会变成 fulfilled
  • 只要 p1p2p3 之中有一个被 rejectedp 的状态就变成 rejected,此时第一个被 reject 的实例的返回值,会传递给p的回调函数。
实现
static all = (promises: MyPromise[]) => {
  return new MyPromise((resolve, reject) => {
    let result: MyPromise[] = [];
    let count = 0;

    for (let i = 0; i < promises.length; i++) {
      promises[i].then(data => {
        result[i] = data;
        if (++count == promises.length) {
          resolve(result);
        }
      }, error => {
        reject(error);
      });
    }
  });
}
例子
let Promise1 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve('Promise1');
  }, 2000);
});

let Promise2 = new MyPromise((resolve, reject) => {
  resolve('Promise2');
});

let Promise3 = new MyPromise((resolve, reject) => {
  resolve('Promise3');
})

let Promise4 = new MyPromise((resolve, reject) => {
  reject('Promise4');
})

let p = MyPromise.all([Promise1, Promise2, Promise3, Promise4]);

p.then((res) => {
  // 三个都成功则成功  
  console.log('---成功了', res);
}).catch((error) => {
  // 只要有失败,则失败 
  console.log('---失败了', err);
});

// 直接输出:---失败了 Promise4

Promise.race

Promise.race()方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。

const p = Promise.race([p1, p2, p3]);

只要 p1p2p3 之中有一个实例率先改变状态,p 的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 p 的回调函数。

实现
static race = (promises) => {
  return new Promise((resolve,reject)=>{
    for(let i = 0; i < promises.length; i++){
      promises[i].then(resolve,reject)
    };
  })
}
例子

例子和 all 一样,调用如下:

// ...

let p = MyPromise.race([Promise1, Promise2, Promise3, Promise4])

p.then((res) => { 
  console.log('---成功了', res);
}).catch((error) => {
  console.log('---失败了', err);
});

// 直接输出:---成功了 Promise2

Promise.allSettled

此方法接受一组 Promise 实例作为参数,包装成一个新的 Promise 实例。

const p = Promise.race([p1, p2, p3]);

只有等到所有这些参数实例都返回结果,不管是 fulfilled 还是 rejected,而且该方法的状态只可能变成 fulfilled

此方法与 Promise.all 的区别是 all 无法确定所有请求都结束,因为在 all 中,如果有一个被 Promiserejectedp 的状态就立马变成 rejected,有可能有些异步请求还没走完。
实现
static allSettled = (promises: MyPromise[]) => {
  return new MyPromise((resolve) => {
    let result: MyPromise[] = [];
    let count = 0;
    for (let i = 0; i < promises.length; i++) {
      promises[i].finally(res => {
        result[i] = res;
        if (++count == promises.length) {
          resolve(result);
        }
      })
    }
  });
}
例子

例子和 all 一样,调用如下:

let p = MyPromise.allSettled([Promise1, Promise2, Promise3, Promise4])

p.then((res) => {
  // 三个都成功则成功  
  console.log('---成功了', res);
}, err => {
  // 只要有失败,则失败 
  console.log('---失败了', err);
})

// 2s 后输出:---成功了 (4) ["Promise1", "Promise2", "Promise3", "Promise4"]

 

总结

这篇文章笔者带大家一步一步的实现了符合 Promise/A+ 规范的的 Promise,看完之后相信大家基本上也能够自己独立写出一个 Promise 来了。

最后通过几个问题,大家可以看看自己掌握的如何:

  • Promise 中是如何实现回调函数返回值穿透的?
  • Promise 出错后,是怎么通过 冒泡 传递给最后那个捕获异常的函数?
  • Promise 如何支持链式调用?
  • 怎么将 Promise.then 包装成一个微任务?

实不相瞒,想要个赞!

 

参考文档

 

示例代码

示例代码可以看这里:

查看原文

赞 0 收藏 0 评论 0

Darrell 赞了文章 · 5月14日

前端都该懂的浏览器工作原理,你懂了吗?

前言

在我们面试过程中,面试官经常会问到这么一个问题,那就是从在浏览器地址栏中输入URL到页面显示,浏览器到底发生了什么?这个问题看起来是老生常谈,但是这个问题回答的好坏,确实可以很好的反映出面试者知识的广度和深度。

本文从浏览器角度来告诉你,URL后输入后按回车,浏览器内部究竟发生了什么,读完本文后,你将了解到:

  • 浏览器内有哪些进程,这些进程都有些什么作用
  • 浏览器地址输入URL后,内部的进程、线程都做了哪些事
  • 我们与浏览器交互时,内部进程是怎么处理这些交互事件的

原文地址 欢迎star

浏览器架构

在讲浏览器架构之前,先理解两个概念,进程线程

进程(process)是程序的一次执行过程,是一个动态概念,是程序在执行过程中分配和管理资源的基本单位,线程(thread)是CPU调度和分派的基本单位,它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

简单的说呢,进程可以理解成正在执行的应用程序,而线程呢,可以理解成我们应用程序中的代码的执行器。而他们的关系可想而知,线程是跑在进程里面的,一个进程里面可能有一个或者多个线程,而一个线程,只能隶属于一个进程。

大家都知道,浏览器属于一个应用程序,而应用程序的一次执行,可以理解为计算机启动了一个进程,进程启动后,CPU会给该进程分配相应的内存空间,当我们的进程得到了内存之后,就可以使用线程进行资源调度,进而完成我们应用程序的功能。

而在应用程序中,为了满足功能的需要,启动的进程会创建另外的新的进程来处理其他任务,这些创建出来的新的进程拥有全新的独立的内存空间,不能与原来的进程内向内存,如果这些进程之间需要通信,可以通过IPC机制(Inter Process Communication)来进行。

进程1

很多应用程序都会采取这种多进程的方式来工作,因为进程和进程之间是互相独立的它们互不影响,也就是说,当其中一个进程挂掉了之后,不会影响到其他进程的执行,只需要重启挂掉的进程就可以恢复运行。

浏览器的多进程架构

假如我们去开发一个浏览器,它的架构可以是一个单进程多线程的应用程序,也可以是一个使用IPC通信的多进程应用程序。

不同的浏览器使用不同的架构,下面主要以Chrome为例,介绍浏览器的多进程架构。

在Chrome中,主要的进程有4个:

  • 浏览器进程 (Browser Process):负责浏览器的TAB的前进、后退、地址栏、书签栏的工作和处理浏览器的一些不可见的底层操作,比如网络请求和文件访问。
  • 渲染进程 (Renderer Process):负责一个Tab内的显示相关的工作,也称渲染引擎。
  • 插件进程 (Plugin Process):负责控制网页使用到的插件
  • GPU进程 (GPU Process):负责处理整个应用程序的GPU任务

进程关系

这4个进程之间的关系是什么呢?

首先,当我们是要浏览一个网页,我们会在浏览器的地址栏里输入URL,这个时候Browser Process会向这个URL发送请求,获取这个URL的HTML内容,然后将HTML交给Renderer ProcessRenderer Process解析HTML内容,解析遇到需要请求网络的资源又返回来交给Browser Process进行加载,同时通知Browser Process,需要Plugin Process加载插件资源,执行插件代码。解析完成后,Renderer Process计算得到图像帧,并将这些图像帧交给GPU ProcessGPU Process将其转化为图像显示屏幕。

进程关系

多进程架构的好处

Chrome为什么要使用多进程架构呢?

第一,更高的容错性。当今WEB应用中,HTML,JavaScript和CSS日益复杂,这些跑在渲染引擎的代码,频繁的出现BUG,而有些BUG会直接导致渲染引擎崩溃,多进程架构使得每一个渲染引擎运行在各自的进程中,相互之间不受影响,也就是说,当其中一个页面崩溃挂掉之后,其他页面还可以正常的运行不收影响。

浏览器容错性

第二,更高的安全性和沙盒性(sanboxing)。渲染引擎会经常性的在网络上遇到不可信、甚至是恶意的代码,它们会利用这些漏洞在你的电脑上安装恶意的软件,针对这一问题,浏览器对不同进程限制了不同的权限,并为其提供沙盒运行环境,使其更安全更可靠

第三,更高的响应速度。在单进程的架构中,各个任务相互竞争抢夺CPU资源,使得浏览器响应速度变慢,而多进程架构正好规避了这一缺点。

多进程架构优化

之前的我们说到,Renderer Process的作用是负责一个Tab内的显示相关的工作,这就意味着,一个Tab,就会有一个Renderer Process,这些进程之间的内存无法进行共享,而不同进程的内存常常需要包含相同的内容。

浏览器的进程模式

为了节省内存,Chrome提供了四种进程模式(Process Models),不同的进程模式会对 tab 进程做不同的处理。

  • Process-per-site-instance (default) - 同一个 site-instance 使用一个进程
  • Process-per-site - 同一个 site 使用一个进程
  • Process-per-tab - 每个 tab 使用一个进程
  • Single process - 所有 tab 共用一个进程

这里需要给出 site 和 site-instance 的定义

  • site 指的是相同的 registered domain name(如: google.com ,bbc.co.uk)和scheme (如:https://)。比如a.baidu.com和b.baidu.com就可以理解为同一个 site(注意这里要和 Same-origin policy 区分开来,同源策略还涉及到子域名和端口)。
  • site-instance 指的是一组 connected pages from the same site,这里 connected 的定义是 can obtain references to each other in script code 怎么理解这段话呢。满足下面两中情况并且打开的新页面和旧页面属于上面定义的同一个 site,就属于同一个 site-instance

    • 用户通过<a target="_blank">这种方式点击打开的新页面
    • JS代码打开的新页面(比如 window.open)

理解了概念之后,下面解释四个进程模式

首先是Single process,顾名思义,单进程模式,所有tab都会使用同一个进程。接下来是Process-per-tab ,也是顾名思义,每打开一个tab,会新建一个进程。而对于Process-per-site,当你打开 a.baidu.com 页面,在打开 b.baidu.com 的页面,这两个页面的tab使用的是共一个进程,因为这两个页面的site相同,而如此一来,如果其中一个tab崩溃了,而另一个tab也会崩溃。

Process-per-site-instance 是最重要的,因为这个是 Chrome 默认使用的模式,也就是几乎所有的用户都在用的模式。当你打开一个 tab 访问 a.baidu.com ,然后再打开一个 tab 访问 b.baidu.com,这两个 tab 会使用两个进程。而如果你在 a.baidu.com 中,通过JS代码打开了 b.baidu.com 页面,这两个 tab 会使用同一个进程

默认模式选择

那么为什么浏览器使用Process-per-site-instance作为默认的进程模式呢?

Process-per-site-instance兼容了性能与易用性,是一个比较中庸通用的模式。

  • 相较于 Process-per-tab,能够少开很多进程,就意味着更少的内存占用
  • 相较于 Process-per-site,能够更好的隔离相同域名下毫无关联的 tab,更加安全

导航过程都发生了什么

前面我们讲了浏览器的多进程架构,讲了多进程架构的各种好处,和Chrome是怎么优化多进程架构的,下面从用户浏览网页这一简单的场景,来深入了解进程和线程是如何呈现我们的网站页面的。

网页加载过程

之前我们我们提到,tab以外的大部分工作由浏览器进程Browser Process负责,针对工作的不同,Browser Process 划分出不同的工作线程:

  • UI thread:控制浏览器上的按钮及输入框;
  • network thread:处理网络请求,从网上获取数据;
  • storage thread: 控制文件等的访问;

浏览器进程线程

第一步:处理输入

当我们在浏览器的地址栏输入内容按下回车时,UI thread会判断输入的内容是搜索关键词(search query)还是URL,如果是搜索关键词,跳转至默认搜索引擎对应都搜索URL,如果输入的内容是URL,则开始请求URL。

处理输入

第二步:开始导航

回车按下后,UI thread将关键词搜索对应的URL或输入的URL交给网络线程Network thread,此时UI线程使Tab前的图标展示为加载中状态,然后网络进程进行一系列诸如DNS寻址,建立TLS连接等操作进行资源请求,如果收到服务器的301重定向响应,它就会告知UI线程进行重定向然后它会再次发起一个新的网络请求。

开始导航

第三步:读取响应

network thread接收到服务器的响应后,开始解析HTTP响应报文,然后根据响应头中的Content-Type字段来确定响应主体的媒体类型(MIME Type),如果媒体类型是一个HTML文件,则将响应数据交给渲染进程(renderer process)来进行下一步的工作,如果是 zip 文件或者其它文件,会把相关数据传输给下载管理器。

与此同时,浏览器会进行 Safe Browsing 安全检查,如果域名或者请求内容匹配到已知的恶意站点,network thread 会展示一个警告页。除此之外,网络线程还会做 CORB(Cross Origin Read Blocking)检查来确定那些敏感的跨站数据不会被发送至渲染进程。

第四步:查找渲染进程

各种检查完毕以后,network thread 确信浏览器可以导航到请求网页,network thread 会通知 UI thread 数据已经准备好,UI thread 会查找到一个 renderer process 进行网页的渲染。

查找渲染进程

浏览器为了对查找渲染进程这一步骤进行优化,考虑到网络请求获取响应需要时间,所以在第二步开始,浏览器已经预先查找和启动了一个渲染进程,如果中间步骤一切顺利,当 network thread 接收到数据时,渲染进程已经准备好了,但是如果遇到重定向,这个准备好的渲染进程也许就不可用了,这个时候会重新启动一个渲染进程。

第五步:提交导航

到了这一步,数据和渲染进程都准备好了,Browser Process 会向 Renderer Process 发送IPC消息来确认导航,此时,浏览器进程将准备好的数据发送给渲染进程,渲染进程接收到数据之后,又发送IPC消息给浏览器进程,告诉浏览器进程导航已经提交了,页面开始加载。

提交导航

这个时候导航栏会更新,安全指示符更新(地址前面的小锁),访问历史列表(history tab)更新,即可以通过前进后退来切换该页面。

第六步:初始化加载完成

当导航提交完成后,渲染进程开始加载资源及渲染页面(详细内容下文介绍),当页面渲染完成后(页面及内部的iframe都触发了onload事件),会向浏览器进程发送IPC消息,告知浏览器进程,这个时候UI thread会停止展示tab中的加载中图标。

网页渲染原理

导航过程完成之后,浏览器进程把数据交给了渲染进程,渲染进程负责tab内的所有事情,核心目的就是将HTML/CSS/JS代码,转化为用户可进行交互的web页面。那么渲染进程是如何工作的呢?

渲染进程中,包含线程分别是:

  • 一个主线程(main thread)
  • 多个工作线程(work thread)
  • 一个合成器线程(compositor thread)
  • 多个光栅化线程(raster thread)

浏览器进程中线程

不同的线程,有着不同的工作职责。

构建DOM

当渲染进程接受到导航的确认信息后,开始接受来自浏览器进程的数据,这个时候,主线程会解析数据转化为DOM(Document Object Model)对象。

DOM为WEB开发人员通过JavaScript与网页进行交互的数据结构及API。

子资源加载

在构建DOM的过程中,会解析到图片、CSS、JavaScript脚本等资源,这些资源是需要从网络或者缓存中获取的,主线程在构建DOM过程中如果遇到了这些资源,逐一发起请求去获取,而为了提升效率,浏览器也会运行预加载扫描(preload scanner)程序,如果HTML中存在imglink等标签,预加载扫描程序会把这些请求传递给Browser Process的network thread进行资源下载。

加载子资源

JavaScript的下载与执行

构建DOM过程中,如果遇到<script>标签,渲染引擎会停止对HTML的解析,而去加载执行JS代码,原因在于JS代码可能会改变DOM的结构(比如执行document.write()等API)

不过开发者其实也有多种方式来告知浏览器应对如何应对某个资源,比如说如果在<script> 标签上添加了 asyncdefer 等属性,浏览器会异步的加载和执行JS代码,而不会阻塞渲染。

样式计算 - Style calculation

DOM树只是我们页面的结构,我们要知道页面长什么样子,我们还需要知道DOM的每一个节点的样式。主线程在解析页面时,遇到<style>标签或者<link>标签的CSS资源,会加载CSS代码,根据CSS代码确定每个DOM节点的计算样式(computed style)。

计算样式是主线程根据CSS样式选择器(CSS selectors)计算出的每个DOM元素应该具备的具体样式,即使你的页面没有设置任何自定义的样式,浏览器也会提供其默认的样式。

样式计算

布局 - Layout

DOM树和计算样式完成后,我们还需要知道每一个节点在页面上的位置,布局(Layout)其实就是找到所有元素的几何关系的过程。

主线程会遍历DOM 及相关元素的计算样式,构建出包含每个元素的页面坐标信息及盒子模型大小的布局树(Render Tree),遍历过程中,会跳过隐藏的元素(display: none),另外,伪元素虽然在DOM上不可见,但是在布局树上是可见的。

layout

绘制 - Paint

布局 layout 之后,我们知道了不同元素的结构,样式,几何关系,我们要绘制出一个页面,我们要需要知道每个元素的绘制先后顺序,在绘制阶段,主线程会遍历布局树(layout tree),生成一系列的绘画记录(paint records)。绘画记录可以看做是记录各元素绘制先后顺序的笔记。

paint

合成 - Compositing

文档结构、元素的样式、元素的几何关系、绘画顺序,这些信息我们都有了,这个时候如果要绘制一个页面,我们需要做的是把这些信息转化为显示器中的像素,这个转化的过程,叫做光栅化(rasterizing)。

那我们要绘制一个页面,最简单的做法是只光栅化视口内(viewport)的网页内容,如果用户进行了页面滚动,就移动光栅帧(rastered frame)并且光栅化更多的内容以补上页面缺失的部分,如下:

最简单的光栅化过程

Chrome第一个版本就是采用这种简单的绘制方式,这一方式唯一的缺点就是每当页面滚动,光栅线程都需要对新移进视图的内容进行光栅化,这是一定的性能损耗,为了优化这种情况,Chrome采取一种更加复杂的叫做合成(compositing)的做法。

那么,什么是合成?合成是一种将页面分成若干层,然后分别对它们进行光栅化,最后在一个单独的线程 - 合成线程(compositor thread)里面合并成一个页面的技术。当用户滚动页面时,由于页面各个层都已经被光栅化了,浏览器需要做的只是合成一个新的帧来展示滚动后的效果罢了。页面的动画效果实现也是类似,将页面上的层进行移动并构建出一个新的帧即可。

合成的光栅化过程

为了实现合成技术,我们需要对元素进行分层,确定哪些元素需要放置在哪一层,主线程需要遍历渲染树来创建一棵层次树(Layer Tree),对于添加了 will-change CSS 属性的元素,会被看做单独的一层,没有 will-change CSS属性的元素,浏览器会根据情况决定是否要把该元素放在单独的层。

layer tree

你可能会想要给页面上所有的元素一个单独的层,然而当页面的层超过一定的数量后,层的合成操作要比在每个帧中光栅化页面的一小部分还要慢,因此衡量你应用的渲染性能是十分重要的一件事情。

一旦Layer Tree被创建,渲染顺序被确定,主线程会把这些信息通知给合成器线程,合成器线程开始对层次数的每一层进行光栅化。有的层的可以达到整个页面的大小,所以合成线程需要将它们切分为一块又一块的小图块(tiles),之后将这些小图块分别进行发送给一系列光栅线程(raster threads)进行光栅化,结束后光栅线程会将每个图块的光栅结果存在GPU Process的内存中。

光栅线程创建图块的位图并发送给GPU

为了优化显示体验,合成线程可以给不同的光栅线程赋予不同的优先级,将那些在视口中的或者视口附近的层先被光栅化。

当图层上面的图块都被栅格化后,合成线程会收集图块上面叫做绘画四边形(draw quads)的信息来构建一个合成帧(compositor frame)。

  • 绘画四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。
  • 合成帧:代表页面一个帧的内容的绘制四边形集合

以上所有步骤完成后,合成线程就会通过IPC向浏览器进程(browser process)提交(commit)一个渲染帧。这个时候可能有另外一个合成帧被浏览器进程的UI线程(UI thread)提交以改变浏览器的UI。这些合成帧都会被发送给GPU从而展示在屏幕上。如果合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给GPU来更新页面。

合成线程构建出合成帧,合成帧会被发送给浏览器进程然后再发送给GPU

合成的好处在于这个过程没有涉及到主线程,所以合成线程不需要等待样式的计算以及JavaScript完成执行。这就是为什么合成器相关的动画最流畅,如果某个动画涉及到布局或者绘制的调整,就会涉及到主线程的重新计算,自然会慢很多。

浏览器对事件的处理

当页面渲染完毕以后,TAB内已经显示出了可交互的WEB页面,用户可以进行移动鼠标、点击页面等操作了,而当这些事件发生时候,浏览器是如何处理这些事件的呢?

以点击事件(click event)为例,让鼠标点击页面时候,首先接受到事件信息的是Browser Process,但是Browser Process只知道事件发生的类型和发生的位置,具体怎么对这个点击事件进行处理,还是由Tab内的Renderer Process进行的。Browser Process接受到事件后,随后便把事件的信息传递给了渲染进程,渲染进程会找到根据事件发生的坐标,找到目标对象(target),并且运行这个目标对象的点击事件绑定的监听函数(listener)。

点击事件从浏览器进程路由到渲染进程

渲染进程中合成器线程接收事件

前面我们说到,合成器线程可以独立于主线程之外通过已光栅化的层创建组合帧,例如页面滚动,如果没有对页面滚动绑定相关的事件,组合器线程可以独立于主线程创建组合帧,如果页面绑定了页面滚动事件,合成器线程会等待主线程进行事件处理后才会创建组合帧。那么,合成器线程是如何判断出这个事件是否需要路由给主线程处理的呢?

由于执行 JS 是主线程的工作,当页面合成时,合成器线程会标记页面中绑定有事件处理器的区域为非快速滚动区域(non-fast scrollable region),如果事件发生在这些存在标注的区域,合成器线程会把事件信息发送给主线程,等待主线程进行事件处理,如果事件不是发生在这些区域,合成器线程则会直接合成新的帧而不用等到主线程的响应。

非快速滚动区域有用户事件发生

而对于非快速滚动区域的标记,开发者需要注意全局事件的绑定,比如我们使用事件委托,将目标元素的事件交给根元素body进行处理,代码如下:

document.body.addEventListener('touchstart', event => {
  if (event.target === area) {
    event.preventDefault()
  }
})

在开发者角度看,这一段代码没什么问题,但是从浏览器角度看,这一段代码给body元素绑定了事件监听器,也就意味着整个页面都被编辑为一个非快速滚动区域,这会使得即使你的页面的某些区域没有绑定任何事件,每次用户触发事件时,合成器线程也需要和主线程通信并等待反馈,流畅的合成器独立处理合成帧的模式就失效了。

当整个页面都是非快速滚动区域时页面的事件处理示意图

其实这种情况也很好处理,只需要在事件监听时传递passtive参数为 true,passtive会告诉浏览器你既要绑定事件,又要让组合器线程直接跳过主线程的事件处理直接合成创建组合帧。

document.body.addEventListener('touchstart', 
event => {
    if (event.target === area) {
        event.preventDefault()
    }
 }, {passive: true});

查找事件的目标对象(event target)

当合成器线程接收到事件信息,判定到事件发生不在非快速滚动区域后,合成器线程会向主线程发送这个时间信息,主线程获取到事件信息的第一件事就是通过命中测试(hit test)去找到事件的目标对象。具体的命中测试流程是遍历在绘制阶段生成的绘画记录(paint records)来找到包含了事件发生坐标上的元素对象。

当整个页面都是非快速滚动区域时页面的事件处理示意图

浏览器对事件的优化

一般我们屏幕的帧率是每秒60帧,也就是60fps,但是某些事件触发的频率超过了这个数值,比如wheel,mousewheel,mousemove,pointermove,touchmove,这些连续性的事件一般每秒会触发60~120次,假如每一次触发事件都将事件发送到主线程处理,由于屏幕的刷新速率相对来说较低,这样使得主线程会触发过量的命中测试以及JS代码,使得性能有了没必要是损耗。

事件淹没了屏幕刷新的时间轴,导致页面很卡顿

出于优化的目的,浏览器会合并这些连续的事件,延迟到下一帧渲染是执行,也就是requestAnimationFrame之前。

和之前相同的事件轴,可是这次事件被合并并延迟调度了

而对于非连续性的事件,如keydown,keyup,mousedown,mouseup,touchstart,touchend等,会直接派发给主线程去执行。

总结

浏览器的多进程架构,根据不同的功能划分了不同的进程,进程内不同的使命划分了不同的线程,当用户开始浏览网页时候,浏览器进程进行处理输入、开始导航请求数据、请求响应数据,查找新建渲染进程,提交导航,之后渲染又进行了解析HTML构建DOM、构建过程加载子资源、下载并执行JS代码、样式计算、布局、绘制、合成,一步一步的构建出一个可交互的WEB页面,之后浏览器进程又接受页面的交互事件信息,并将其交给渲染进程,渲染进程内主进程进行命中测试,查找目标元素并执行绑定的事件,完成页面的交互。

本文大部分内容也是对inside look at modern web browser系列文章的整理、解读和翻译吧,整理过程还是收获非常大的,希望读者读了本文只有有所启发吧。

相关参考链接

查看原文

赞 124 收藏 94 评论 20

Darrell 发布了文章 · 5月12日

使用 React Hooks 定制一个多级下拉的 TextArea 组件(巨详细)

前言

最近在业务中遇到了一个关于 多级下拉 需求,需要将后端树状数据显示在 textarea 上,同时 textArea 中也能对数据进行处理,转化为能进行多级选择树状数据。

拿问卷星的多级下拉举个例子,如下图所示,用户可以在 textArea 框进行多级下拉的数据的编写,第一行代表标题,余下的每一行代表一个多级下拉框中各级的数据,各级数据之间使用 / 来进行分隔。

数据编辑完成保存之后,我们将树状数据用在移动端或者小程序端,这样就完成了一个多级下拉的组件。

今天这篇文章就简单介绍一下这个工作流程,主要包括:

  • 怎样将 树状数据 转化为 textarea 上展示的 value 值 ?
  • 怎样将 textarea 中的数据 转化为 树状数据
  • 怎样判断 哪些数据已存在的,哪些数据是新增的,哪些数据是删除的 ?
  • 怎么测试一个将要发布到 npm 的组件?

关于多级下拉的 数据展示 在这篇文章中不会做介绍,那么接下来我们就开始发车。

 

项目说明

项目预览图

 

技术栈

这个组件是使用 React Hooks + TypeScript 来实现,众所周知,HooksReact 未来的趋势,同时 TypeScript 也是 JavaScript 未来的趋势,小弟刚好拿这个组件练练手。

打包工具用的是 Rollup,因为打包组件库的 RollupWebpack 更受欢迎,Webpack 更适合打包复杂的大型项目。关于 Webpack 的学习,大家可以参考笔者整理的 Webpack 学习文档

 

项目结构

项目结构如下所示:

.
├── node_modules // 第三方的依赖
├── example    // 开发时预览代码
    ├── public // 放置静态资源文件夹
    ├── src    // 示例代码目录
      ├── app.js     // 测试项目 入口 js 文件
      └── index.html // 测试项目 入口 html 文件
    ├── yarn.lock    // 测试项目 yarn lock 文件
    └── package.json // 测试项目 依赖
├── src        // 组件源代码目录
    ├── components     // 轮子的目录
      ├──textarea // 项目内部使用的一个 textarea 组件
      ├── index.less     // 组件核心代码样式文件
      └── textarea.tsx // 组件核心代码
    ├── types  // typescripe 的接口定义
    ├── utils  // 工具函数目录
    ├── assets  // 静态资源目录
    ├── index.tsx  // 项目入口文件
    └── index.less   // 项目入口样式文件
├── lib  // 组件打包结果目录
├── test // 测试文件夹
├── typings  // 放置项目全局 ts 申明文件目录
├── .babelrc // babel 配置文件
├── .eslintignore // eslintignore 配置文件
├── .eslintrc.js // eslint 配置文件
├── .gitignore // git上传时忽略的文件
├── api-extractor.json // 用于将多个 ts 声明文件合成一个
├── jest.config.js // 测试配置文件
├── .npmignore // npm 上传忽略文件
├── README.md
├── tsconfig.eslint.json // ts 的 eslint 文件
├── tsconfig.json // ts 的配置文件
├── rollup.config.js // rollup 打包配置文件
├── yarn.lock    // yarn lock 文件
└── package.json // 当前整一个项目的依赖
对于项目中除了源码以外的一些知识点,笔者就不细说了,比如 Rollup 如何配置;api-extractor 如何将多个声明文件生成一个等等,大家可以自行查阅一波。

 

仓库地址

仓库地址在此:多级下拉 textarea 组件

 

Hooks 骨架代码

我们使用的 React Hooks 来编写这个组件,一般编写组件之前我们需要明确这个组件该支持哪些功能,即支持哪些 props,在这个组件中暂时支持下面这些参数:

<TreeTextArea
  treeTitle={title} // 多级下拉 标题数据
  treeData={tree_value} // 树状数据
  row={21} // textarea 的行数
  showNumber // 是否展示左侧 textarea 数字
  shouleGetTreeData // 是否开启 处理树数据的功能
  delimiter='/'     // 以什么符号切割
  maxLevel={4}      // 支持的最大级数
  onChangeTreeData={  // 与 shouleGetTreeData 进行搭配使用,返回处理后的标题和树状数据
    (treeTitle, treeData) => {
      console.log('---treeTitle---', treeTitle);
      console.log('---treeData---', treeData);
    }
  }
  defaultData={DEFAULT_TEXT} // 树状数据默认值
  placeholder='请输入标题,例:省份/城市/区县/学校&#10;浙江省/宁波市/江北区/学校1'
/>

我们在 src/components/textarea.tsx 中进行相应代码的编写,其中包括接收相应的传入相应的 props 值、刚进入页面的时候去 初始化数据监听数据变化获取树状值 等操作:

const TreeTextArea = (props: Props): JSX.Element => {
  // 一系列 props 数据的接受
  // ...
  const [__textAreaData, setTextAreaData] = useState('');
  const [__flattenData, setFlattenData] = useState([]);
  
  // 数据初始化
  useEffect(()=>{
    if (isArray(__treeData) && isArray(__treeTitle)) {
      // ...

      const flattenData = flattenChainedData(__treeData);
      const textAreaData = getTextAreaData(flattenData, titles);

      setFlattenData(flattenData);
      setTextAreaData(textAreaData.join('\n'));
    }

    return ()=>{
      // willUnMount
    }
  }, [])
  
  // 监听数据变化
  const onChange = (data: any): void => {}
  
  // 设置默认值
  const getDefaultData = (): void => {}
  
  // 获取树状值
  const getTreeData = (e: any): void => {
    const { onChangeTreeData } = props;
    // ...
    if (onChangeTreeData) {
      onChangeTreeData(levelTitles, valueData);
    }
  }

  return (
    <div className={styles.wrapper}>
      <NumberTextArea
        row={__row}
        value={__textAreaData}
        onChange={onChange}
        showNumber={__showNumber}
        placeholder={__placeholder}
        errCode={__errCode}
        errText={__errText}
      />
      {
        // ...
        // 填充默认值、获取树状值 代码
      }
    </div>
  )
}

我们内部还封装了一个 NumberTextArea,在这个组件中我增加了 左侧序号显示错误显示 等逻辑,具体的代码就不贴上来了,大家有兴趣可以参考源码。

关于相关的 React Hooks 知识大家可以自行查阅相关资料学习,笔者在这里也不做介绍了。

接下来我们来看一下组件中最核心的 多级下拉逻辑处理

 

多级下拉逻辑核心代码

在整体骨架代码搭建好之后,我们就只需关注 textarea 处理数据的逻辑就行了。

首先我们在 utils 目录下新建 testData.ts,模拟后端的 json 数据,如下图所示:

 

数据渲染

我们先从编辑开始说起,假如后端给了我们要渲染的标题以及多级下拉的树状数据:

接着我们希望通过一些处理将后端给的数据修改成 textarea 中可以展示的 value 值,类似于下面的字符串作为 value 值:

这里我们需要做的是将 树状数据 进行 扁平化处理,给每一级的数据增加一个 title 属性,这便是我们需要在 textarea 每一行中所要展示的数据,类似如下的数据:

我们需要将每一级的数据的都扁平化出来。

但这里有一个问题比如 浙江省/宁波市浙江省/宁波市/海曙区 这是两个不同的数据,但是在 textarea 中其实不需要展示 浙江省/宁波市 这一个数据的,所以我在这里做了一个判断,如果这一个数据有孩子的话,就给他增加一个属性 hasChildren,让我们在获取 textarea 的数据的时候做一下过滤就行了,不展示有属性 hasChildren 的数据。

那么我们如何来扁平化数据呢?其实只要对树状数据做一下递归处理就行了。

/**
 * 将后端的 树状结构 数据 扁平化
 * @param {Array} data : 后端 tree_node 数据
 */
export const flattenChainedData = (data: any) => {
  let arr = [];

  forEach(data, (item) => {
    const childrens = item.children;
    const rootObj = createNewObj(item, item.value);
    if (childrens) {
      rootObj.hasChildren = true;
    }

    arr.push(rootObj);

    if (childrens) {
      // 递归获得所有扁平的数据
      const dataNew = getChildFlattenData(childrens, item.value, 1);
      arr = concat(arr, dataNew);
    }
  });

  return arr;
};

/**
 * 递归获得 扁平数组
 * @param {*} data : 要处理的数组
 * @param {*} title : 前几级 拼的 title
 * @param {*} level : 当前级数
 */
const getChildFlattenData = (data, title, level) => {
  // 超过最大级数
  if (level > MAX_LEVEL) return false;
  if (!data) return false;

  let arr = [];

  forEach(data, (item) => {
    const { children } = item;
    const rootObj = createNewObj(item, `${title}/${item.value}`);

    if (children) {
      rootObj.hasChildren = true;
      const childrenData = getChildFlattenData(children, `${title}/${item.value}`, level + 1);
      arr.push(rootObj);
      arr = concat(arr, childrenData);
    } else {
      arr.push(rootObj);
    }
  });

  return arr;
};

其中上面的 createNewObj 是为扁平数据新增 value/title 属性,返回新的对象,具体就不上代码了。

转化为扁平数据之后,我们就可以将数据中的 title 属性拿出来,组成 textarea 所需的数据即可:

/**
 * 将 扁平数据 转化为 textarea 中 value
 * @param {Array} flattenData : 扁平化数据
 * @param {String} titles : textarea 第一行的 title
 */
export const getTextAreaData = (flattenData, titles) => {
  const newData = filter(flattenData, (item) => {
    return !item.hasChildren && item.status !== 2;
  });

  const arr = [];

  arr.push(titles);

  forEach(newData, (item) => {
    arr.push(item.title);
  });

  return arr;
};

其中我们过滤了 hasChildrentrue,同时这里以 status = 2 表示删除的数据,也进行过滤, 这样我们便可以得到如下图所示的一个 textarea 数组:

接着我们将这些数组通过 \n 换行符 join 起来就是我们所需的 textareavalue 值了。

 

树状数据处理

这里是整个多级下拉逻辑中最核心的部分,我们需要将用户修改过的数据,与原来的数据进行关联,生成新的树状数据。我们分为四个步骤来讲解这一个步骤。

我们会创建一个 数据处理类 treeTextAreaDataHandle,并在 constructor 构造函数中传入 扁平化数据textarea 文本框的值 来初始化一个实例对象。之后我们会在此类中完善我们处理数据的一些属性和方法。

class treeTextAreaDataHandle {
  // 扁平化数组
  private flattenData: FlattenDataObj[];

  // textarea 框 文本值
  private textAreaTexts: string;

  constructor(options: treeTextAreaData) {
    const { flattenData, textAreaTexts } = options;

    this.flattenData = flattenData;
    this.textAreaTexts = textAreaTexts;
  }
}

生成修改的初始映射

第一步我们会生成用户修改后的 textarea 初始映射数据,我们会根据级数分别放在不同的数组中,举个🌰:

我们会根据用户最后输入完成的 textarea 值,来生成一组根据级数排布的对象数据,如下图:

如上面这张图会转化为如下面图中的数据,这个数据会是我们进行接下去三步操作的关键:

这里需要注意的一个问题,有可能在某一级是有同名的值存在,这个时候我们不能单纯的就把这两个值认为是同一个值,而要去比较他们的爸爸是否是一样的,以此类推,递归比较直到第一级,如果都是一样的话,那么他们才是同一个值,否则就是不同的值。

举个简单例子,比如有两个数据: 浙江省/宁波市/海曙区江苏省/无锡市/海曙区 这两个值,虽然第三级中的 海曙区 名字是相同的,但是他们是两个不同的值,他们应该被分配到两个不同的 id,并且各自的 parent_id 也不一样。

接下来上代码,我们新建一个实例方法 transDataFromText,在这个方法中进行树状数据的转化,并得到最后的数据:

/**
 * 将 textarea 数据 转化为 后端所需的树状结构数据
 * @param {Array} flattenData : 扁平数据
 * @param {String} texts : textarea 的文本
 */
public transDataFromText() {
  const texts = this.textAreaTexts;

  const arr = texts.split('\n');

  // 去除标题
  if (arr.length > 1) {
    arr.shift();
  }

  // 赋值每一行文字为数组
  this.textAreaArr = arr;

  // 解析 TextArea 数据 为 指定 层级映射数据
  this.parserRootData();

  // ...
}

我们在 parserRootData 这个方法中去生成修改后的初始映射

/**
 * 将 textarea 数据 转化为 相应级数 的 数据
 * @param {Array} textArr : textarea 的文本 转化的数组
 * @param {Number} handleLevel : 要处理的级数
 */
private parserRootData() {
  // 每一行的 textArea 值
  const textArr  = this.textAreaArr;
  // 最大级数
  const handleLevel = this.MAX_LEVEL;
  // 以什么分隔符切割
  const delimiter = this.delimiter;

  // 去重 每一级 textArea 值
  const uniqueTextArr = uniq(textArr);

  // 映射数据存放对象
  const namesArrObj: namesArrObj = {};

  // 根据最大级数为每一级创建一个数组
  for (let i = 1; i <= handleLevel; i++) {
    namesArrObj[`${ROOT_ARR_PREFIX}_${i}`] = [];
  }

  // 遍历 每一行的 textArea 值
  forEach(uniqueTextArr, (item: string) => {
    // 切割 每一行 字符串,生成字符串
    const itemArr = item.split(delimiter);
    
    // 根据最大级数往 namesArrObj 塞数据
    for (let i = 1; i <= handleLevel; i++) {
      if (
        !treeTextAreaDataHandle.sameParentNew(namesArrObj, itemArr, i)
        && itemArr[i - 1]
      ) {
        // 创建一个对应级数的对象,塞入对应的数组
        const obj: parserItemObj = {};
        obj.id = _id();
        obj.value = itemArr[i - 1];
        obj.level = i;

        // 获取当前的级数的值,爸爸的 id
        const parentId = treeTextAreaDataHandle.getParentIdNew(
          namesArrObj, itemArr, i
        );
        
        obj.parent_id = parentId;
        
        namesArrObj[`${ROOT_ARR_PREFIX}_${i}`].push(obj);
      }
    }
  });

  // 保存到对象的 rootArrObj 属性值中
  this.rootArrObj = namesArrObj;
}

上面最为关键的一个方法就是静态方法 sameParentNew,作用是帮我们递归判断 两个相同名称的值是否真的相同。其实原理也很简单,也是 递归 判断他们各自的爸爸是否相同。具体代码大家可以参考源码。

其次这里还有用到类似:

  • 创建 id的方法:_id()
  • parent_id 的静态方法:getParentIdNew

到这里我们第一步生成 初始映射 数据就完成了,接下来我们就需要结合后端提供给我们的扁平数据 flattenData 来填充已存在的数据,同时筛选出新增的数据。

 

填充存在数据并筛选新增数据

这一步我们需要将后端给我们的数据 flattenData 与我们的初始映射数据进行比对。填充存在数据的属性同时筛选出新增的数据,并给新增数据加上属性 new = true,最后塞到对应的对象对应级数组中去 existNamesArrObjaddNamesArrObj

举个🌰

我们新增了 浙江省/宁波市/高新区,我们可以在新增数据中的第三级中找到 高新区,因为 浙江省宁波市 已经存在,他们不会被添加到新增的数组中去,只会在已存在的对象中被找到,并且会用后端给的 id 替换掉我们之前生成映射数据是生成的 id,如下图:

存在数据:existNamesArrObj

新增数据:addNamesArrObj

这里我们还要注意的一个点是,我们在进行数据筛选之前,需要将后端给的数据 flattenData 数据中加上一个属性 root_id,它的作用是帮我们将 修改后数据 和之前 后端给的数据 进行关联,比如上面我们新增 高新区 这个例子,他的爸爸是已经存在的,他的 id 是已经存在的 36178,但是新增的高新区的 parent_id 是我们在映射数据时生成的,这两个肯定不相等,我们需要借助 root_id 来将这两个数据联系起来。

接下来上代码,我们将这一波处理放到 handleExistData 方法中,

/**
 * 填充已有的数据,并筛选出新增的数据
 * @param {*} TextAreaData : parserRootData() 处理的数据
 * @param {*} newFlattenData : 扁平化数据
 * @param {Number} handleLevel : 要处理的级数
 */
private handleExistData() {
  const namesArrObj = this.rootArrObj;
  const newFlattenData = this.flattenData;
  const handleLevel = this.MAX_LEVEL;

  // 存在的数据
  const existNamesArrObj = {};
  // 新增的数据
  const addNamesArrObj = {};

  for (let i = 1; i <= handleLevel; i++) {
    addNamesArrObj[`${ADD_ARR_PREFIX}_${i}`] = [];
    existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`] = [];
  }

  // flatten 加上 parser 的 映射 id
  this.setMapIdForFlattenDataToRootData();

  for (let i = 1; i <= handleLevel; i++) {
    // 获取出事映射相应级数的数据
    const curNamesArr = namesArrObj[`${ROOT_ARR_PREFIX}_${i}`];

    forEach(curNamesArr, (item) => {
      // 设立一个标志位
      // 标志这一级的数据是否存在
      let flag = false;
            
      // 映射数据的属性 
      const { value, parent_id, id } = item;

      // 新增数据 obj
      const addNewObj: addNewObj = {
        level: i,
        value,
        id,
        new: true,
        root_id: id,
      };

         // 遍历比较后端数据 与 映射数据 的 `value` 和 `level`
      // 来确定他们映射数据是否存在
      // 存在就
      forEach(newFlattenData, (val) => {
        if (value === val.value) {
          if (val.level === i) {
            // level 等于 1
            if (val.level === 1 && val.parent_id === 0) {
              const obj = { ...val };
              existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`].push(obj);
              flag = true;
            }
            // level 大于 1
            if (val.level !== 1 || val.parent_id !== 0) {
              if (this.isExistitem(val, parent_id, i)) {
                const obj = { ...val };
                existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`].push(obj);
                flag = true;
              }
            }
          }
        }
      });

      // 如果是新增数据
      if (!flag) {
        // 塞入 addNamesArrObj
        addNamesArrObj[`${ADD_ARR_PREFIX}_${i}`].push(addNewObj);
        // 塞入 最新 扁平化数据
        newFlattenData.push(addNewObj);
      }
    });
  }

  // 将 existNamesArrObj 挂到类属性 existNamesArrObj 上
  this.existNamesArrObj = existNamesArrObj;
  this.addNamesArrObj = addNamesArrObj;
}

上面的方法中还用到了一个比较重要的方法 isExistitem 方法,判断当前级数据是否存在,其原理跟parserRootData 中用到的 sameParentNew 类似,也是去递归比较在他们的爸爸是否是相同的,直到找到不同的爸爸或者到第一级为止,只不过这里面比较的是 初始映射数据后端扁平数据

还有一个方法就是我们上面讲到的给 后端扁平数据 添加 root_id 的方法 setMapIdForFlattenDataToRootData,具体代码笔者不贴了,大家有兴趣可以自行查看。

处理完已存在数据,和新增数据,我们还需要处理删除的数据。

这里如果需求中要出对数据进行排序的话,其实可以吧 存在数据新增数据 放在一个对象中,这样每次有新增或者存在数据的时候都会从上都下依次塞入,现在笔者是吧 存在数据新增数据 分开来了,新增数据 默认都是在最后的。

 

处理删除数据

一般来说,如果数据删除了,前端还需要将数据传给后端,告诉后端这条数据删除了。所以我们需要给删除的数据中加上相应的 状态值,这里我们加了 status = 2,代表此条数据在前端已经被删除了。

实现起来很简单,因为我们通过第二步已经得到了 已经存在的数据,只需要拿它与最初的 后端提供的扁平数据 进行比较一波就能得出哪些数据被删除了,筛选出来之后将他们将上相应的属性即可。

比如我们删除了 江苏省/无锡市/惠山区 这一行,其实是删除了 无锡市惠山区 两个数据,我们可以得到如下结果:

接下来上代码,我们将筛选删除数据的方法写在 handleTagForDeleleByLevel 方法中,

/**
 * 根据标题 几级 来获取删除的数据,并给删除数据打上标签,并返回删除数据
 * @param {*} handleDataArr : fillExistData() 处理的数据
 * @param {*} newFlattenData : 扁平化数据
 * @param {Number} handleLevel : 要处理的级数
 */
private handleTagForDeleleByLevel = () => {
  const existNamesArrObj = this.existNamesArrObj;
  const handleLevel = this.MAX_LEVEL;

  // 存放 存在扁平数据 的数组
  let existData = [];

  // 遍历 存在数据对象 扁平化存在数据
  for (let i = 1; i <= handleLevel; i++) {
    const curArray = existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`];
    existData = concat(existData, curArray);
  }

  // 给删除数据添加属性 status = 2
  const deleteData = this.addTagForDeleleData(existData);

  // 将 deleteData 挂到属性 deleteData 上
  this.deleteData = deleteData;
};

我们通过 addTagForDeleleData 方法来比较不同的值,并加上属性 status=2,在这里我们也可以使用lodashdifference 方法来得到两个数组不同的值。

处理完上面三步之后,基本上就大公告成了,接下来就生成最终树状数据。

 

生成树状数据

最后我们就需要将 存在数据新增数据删除数据 生成一个新的扁平化数组,由这个新扁平化数据生成我们想要的树状数据。

比如我们新增 浙江省/宁波市/高新区,删除 江苏省/无锡市/惠山区,最终会得到新的扁平数据如下,我们可以看到 高新区 是新增的,惠山区 也加上了相应的 status=2 的属性:

接着我们就可以根据这个扁平化数据,递归生成树状数据,如下图:

接下来上代码,首先 getLastFlattenData 方法,通过这个方法我们可以获取到最新的扁平化数据:

/**
 * 生成最新的数据
 * @param {*} existNamesArrObj : existNamesArrObj 已存在数据
 * @param {*} addNamesArrObj : addNamesArrObj 新增数据
 * @param {*} deleteData : addTagForDeleleByLevel() 得到的删除数据
 * @param {Number} handleLevel : 要处理的级数
 */
private getLastFlattenData() {
  const existNamesArrObj = this.existNamesArrObj;
  const newAddNamesArrObj = this.newAddNamesArrObj;
  const deleteData = this.deleteData;
  const handleLevel = this.MAX_LEVEL;

  let lastData = [];

  let AddLast = [];
  let ExistLast = [];

  // 遍历 扁平化 存在和新增数据
  for (let i = 1; i <= handleLevel; i++) {
    const curArrayExist = existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`];
    const curArrayAdd = newAddNamesArrObj[`${HANDLE_ADD_ARR_PREFIX}_${i}`];

    ExistLast = concat(ExistLast, curArrayExist);
    AddLast = concat(AddLast, curArrayAdd);
  }

  // 合并三种类型的数据
  lastData = concat(lastData, ExistLast, AddLast, deleteData);

  // 将 lastData 挂到 类属性 newDataLists 上
  this.newDataLists = lastData;
};

最后就是生成最终树状数据,原理就是从 parent_id0 开始进行 递归遍历,直到遍历完所有节点为止,与此同时我们需要在生成树之前,删除一些原本不需要的属性,比如新增属性 new,映射关联的 root_id 等。

/**
 * 删除 之前 组装 树状结构时 使用的 一些自定义属性
 * 后端不需要
 * @param {Object} item : 每一项的 item
 */
static clearParamsInTreeData = (item) => {
  delete item.title;
  delete item.hasChildren;
  delete item.root_id;

  if (item.new) {
    delete item.new;
    delete item.id;
    delete item.parent_id;
  }
};

/**
 * 递归 将扁平数据转化为 树状 结构数据
 * 用于 transDataFromText
 * @param {Array} lists : 扁平数据
 * @param {Number} parent_id : 爸爸的 id
 */
private getTreeDataBylists = (parent_id: number | string): any => {

  const lists = this.newDataLists;

  //递归,菜单
  const tree = [];

  forEach(lists, (item) => {
    const newItemId = item.parent_id;

    if (parent_id === newItemId) {
      const childrenTree = this.getTreeDataBylists(item.id);
      if (isArray(childrenTree) && childrenTree.length > 0) {
        item.children = childrenTree;
      } else {
        item.children = null;
      }

      // 删除不必要属性
      treeTextAreaDataHandle.clearParamsInTreeData(item);
      
      tree.push(item);
    }
  });

  return tree;
};

到这里我们变完成了对 textarea 的处理,最终的 transDataFromText 方法如下:

/**
 * 将 textarea 数据 转化为 后端所需的树状结构数据
 * @param {Array} flattenData : 扁平数据
 * @param {String} texts : textarea 的文本
 */
public transDataFromText() {
  const texts = this.textAreaTexts;

  const arr = texts.split('\n');

  if (arr.length > 1) {
    arr.shift();
  }

  this.textAreaArr = arr;

  // 解析 TextArea 数据 为 指定 层级映射数据
  this.parserRootData();

  // 填充已有数据 并 筛选新增数据
  this.handleExistData();

  // 处理新增数据
  this.handleParamsInAddData();

  // 获取删除数据
  this.handleTagForDeleleByLevel();

  // 获取最新扁平数据
  this.getLastFlattenData();

  // 获取最新树状数据
  this.lastTreeData = this.getTreeDataBylists(0);

  return this.lastTreeData;
}

 

错误处理

我们需要对一些错误进行处理,比如 用户可能不会输入标题、又或者 用户输入的标题大于了最大支持级数(当然在我们项目中,这个最大支持级数用户可以自己来控制)、又或者 标题的级数与下面内容的级数不对应,这些都应该被归为错误列表中。

举个例子,当用户没有输入标题的时候,我们应该提示其输入标题,如下图:

我们新建一个方法 isEquelLevel 方法,来检测用户输入的值是否符合规范,代码其实也很简单,我们可以取到最终的数据,遍历数据中是否存在错误,存在错误就抛出相应的 错误码 errorCode错误信息 ERROR_INFO,错误类型如下:

/**
 * 校验信息
 */
export const ERROR_INFO = {
  1: '第一行标题不可为空',
  2: `第一行标题不可超过 ${MAX_LEVEL} 列`,
  3: '标题和选择项的层级数请保持一致',
  4: `选择项不可超过 ${MAX_LEVEL} 行`,
  5: '请至少填写一行选择项',
};

 

测试

功能写完之后,我们需要测试一下组件的功能,可以借助使用 create-react-appreact-scripts 帮我们快速启动一个应用:

package.json 配置:

以下是测试项目 package.json 文件:

{
  "name": "example",
  "version": "0.0.0",
  "description": "",
  "license": "MIT",
  "private": true,
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject",
  },
  "author": "Darrell",
  "dependencies": {
    "lodash": "^4.17.15",
    "react": "link:../node_modules/react",
    "react-dom": "link:../node_modules/react-dom",
    "react-scripts": "^3.4.1"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}

这里面需要注意的一个问题就是,这里面的 reactreact-dom 两个依赖需要使用上一级根目录 node_modules 下的依赖。

因为使用 Hooks 写的插件会因为有多个 React 应用而报错,如下图:

导致这个问题的原因主要是第一个 React 版本没到 16.8,或者第三个,在项目中有多个 React 引用。

至于第二个问题,Hooks 不符合规范基本上在我们安装了 eslint-plugin-react-hooks 插件之后就基本上可以规避掉了。关于这个问题的更多信息大家可以参考 这条 issure

然后我们进入 exmaple 安装相应的依赖,直接运行 yarn start 就可以将我们的项目跑起来了。

 

主要代码

我们在 examplepublic 目录下新建

  • index.html:项目的模版文件,即负责项目显示的 html 文件
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">

    <link rel="manifest" href="%PUBLIC_URL%/manifest.json">

    <title>测试页面</title>
  </head>

  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>

    <div id="root"></div>
  </body>
</html>
  • manifest.json:如果写手机端的 h5app,图标、主题颜色等是在这个文件里是设置的,在这里我们可以随意配置一波。

同时在 src 目录下新建:

  • index.js:项目入口文件
  • index.css:入口文件的样式
import React from 'react'
import ReactDOM from 'react-dom'

import './index.css'
import App from './App'

ReactDOM.render(<App />, document.getElementById('root'))
  • App.js:测试组件的文件
import React, { Component } from 'react'

// 测试数据
import { title, tree_value, DEFAULT_TEXT } from './testData'

import TreeTextArea from 'darrell-tree-textarea'

export default class App extends Component {
  render () {

    return (
      <div className='App'>
        <TreeTextArea
          treeTitle={title}
          treeData={tree_value}
          row={21}
          showNumber
          shouleGetTreeData
          delimiter='/'
          maxLevel={4}
          onChangeTreeData={
            (treeTitle, treeData) => {
              console.log('---treeTitle---', treeTitle);
              console.log('---treeData---', treeData);
            }
          }
          defaultData={DEFAULT_TEXT}
          placeholder='请输入标题,例:省份/城市/区县/学校&#10;浙江省/宁波市/江北区/学校1'
        />
      </div>
    )
  }
};

 

npm 之前的测试

上面的测试文件是写在我们的组件项目中的。

但是一般在发包之前,我们需要在其他的项目里面测试使用一下,这个时候我们可以借助 npm link

  • 首先在组件下根目录下 执行 npm link,这句命令意思就是将组件引入到全局的 node_modules

  • 在你要使用组件的目录下,通过下面命令引用组件:
npm link <package 名>

看个🌰:假设我们使用 create-react-app 新建了一个项目 my-app,我们就可以在此项目的根目录下面,运行:

npm link @darrell/darrell-tree-textarea

这个时候我们可以在项目中有了我们项目的依赖:

但是因为是项目的引用,所以这个依赖包含了我们插件项目中的所有内容,包括 node_modules,这里会出现我们上面提到的 Hooks 开发组件 Invalid hook call 这个错误,因为在我们的依赖下有 @darrell/darrell-tree-textarea 下有 node_modules 文件下,在它下面有 React 依赖,同时在 my-app 下面的 node_modules 下也有 React 依赖,所以就会出现 多个 React 引用 这个问题。

这个问题在我们发到 npm 上之后不会出现,因为在上传到 npm 上的时候是不会把 node_modules 目录传上去的。

解决办法有两个:

  • 删除 @darrell/darrell-tree-textarea 下的 node_modules,但是每次都需要重新安装
  • 推荐使用,在 my-app 项目下,改一下配置文件,将所有的 react 引用指向同一个引用
alias: {
  // ...
  'react': path.resolve(__dirname, '../node_modules/react'),
  'react-dom': path.resolve(__dirname, '../node_modules/react-dom'),
},

关于如何发包大家可以参考这篇文章:从零开始实现类 antd 分页器(三):发布npm,这篇文章中有详细的介绍组件的测试和 npm 的发布,在本篇文章中就不涉及了。

 

小结

本文主要讲了如何制作一个能处理 多级下拉树状数据textarea 组件的编写,整体来看还是比较简单,整个组件的难点应该是如何有效的 递归处理 数据:

  • 比如如何扁平化树状数据:递归处理他们的 children
  • 如何判断两个名字相同的数据是否相同:递归判断他们的爸爸是否相同 等等问题

还有在组件测试那里也折腾了蛮久的,因为碰到了 React Hooks 组件不能运行的问题,我曾一度以为是 Hooks 的写法有问题,后来没想到是多个 React 引用出现的错误。

不过现在回过头来思考这个问题,发现 React 的错误提醒其实做的很清楚,自己只要跟着这个错误提示一步一步就能把问题解决掉。

实不相瞒,想要个赞!

 

参考内容

查看原文

赞 0 收藏 0 评论 0

Darrell 发布了文章 · 5月8日

使用 React Hooks 实现仿石墨的图片预览插件(巨详细)

前言

最近工作中需要制作一个图片预览的插件,在参考了很多产品(掘金、知乎、简书、石墨等)的图片预览之后,最终还是觉得石墨的比较符合我们的产品需求。

本来以为能在社区中找到相关插件,但想法美好,现实却很骨感,于是便决定自己手撸一个,顺便也学习一下组件的开发流程。

 

项目介绍

项目预览图

项目最终的实现效果如下图,基本上跟石墨的图片预览是一毛一样的。支持 放大图片缩小图片原尺寸大小显示图片适应屏幕下载图片(这个还在开发中),也就是底部栏的五个操作按钮。

技术栈

组件是基于 React HooksTypeScript 实现的,打包工具使用的是 webpack

本篇文章对 webpack 的配置不会做相应的介绍,如果大家对 webpack 感兴趣的话,可以参考笔者整理的 浅谈 Webpack 性能优化(内附巨详细 Webpack 学习笔记)

项目目录

.
├── node_modules // 第三方的依赖
├── config        // webpack 配置文件夹
    ├── webpack.base.js     // webpack 公共配置文件
    ├── webpack.dev.config.js  // 开发环境配置文件
    └── webpack.prod.config.js  // 生产环境配置文件
├── example    // 开发时预览代码
    ├── src    // 示例代码目录
      ├── app.js     // 测试项目 入口 js 文件
      └── index.less // 测试项目 入口 样式文件 文件
├── src        // 组件源代码目录
    ├── components     // 轮子的目录
      ├── photoGallery // photoGallery 组件文件夹
    ├── types  // typescripe 的接口定义
    ├── utils  // 工具函数目录
    ├── images  // 图片文件目录
    ├── index.html  // 项目入口模版文件
    ├── index.tsx      // 项目入口文件
    └── index.less   // 项目入口样式文件
├── lib  // 组件打包结果目录
├── .babelrc // babel 配置文件
├── .gitignore // git上传时忽略的文件
├── .npmignore // npm 上传忽略文件
├── README.md
├── tslint.json // tslint 的配置文件
├── tsconfig.json // ts 的配置文件
├── package-lock.json    // yarn lock 文件
└── package.json // 当前整一个项目的依赖

仓库地址

仓库地址在此:仿石墨的图片预览插件

 

思路分析

此插件的核心在于图片的展示,以及围绕对预览图片进行的操作,如 放大缩小适应屏幕,而这几个操作又都是跟图片的尺寸有关的,其实我们只要知道在点击相应操作的按钮的时候,图片应该显示多大的尺寸,整个问题就解决了。

于是笔者就研究了一波其背后预览逻辑,发现了几个对编码比较有用的点:

首先图片不能一直放大和缩小,它必定有一个最大值和最小值,操作了一波发现石墨中 预览图片的最大值是原图的 4 倍最小值是原图的 10 倍,与此同时还需要规定从原图开始点击几次到最大值或者最小值,在插件中我规定的次数是 6 次。

这样在图片加载完成之后,我们能很方便的算出这张预览图片的所有尺寸,可以将这些尺寸维护在一个数组中,这样在每一个放大缩小的点击背后,都会有一个图片尺寸与其对应。

接着我们需要知道的是当前预览图片的显示尺寸位于 尺寸数组 中的哪一个 index,有了这个 index 之后,我们就只需要取出这个 index 对应的图片宽度进行绘制即可。

这里就涉及到图片首次在容器中的显示情况了,我们拿长图举例:长图预览,插件会在图片上下两侧留出一定的间距,这个间距其实是固定的,在石墨中我算了一下,上下两侧留出的间隙各是容器高度的 5%,具体可以看下图(原谅图片很魔性),图中 A 的距离是 B5%

这样我们可以计算出当前图片的尺寸,拿这个尺寸去 尺寸数组 中找到与这个值最为接近的值,这个最接近的值的索引就是当前预览图片的 index 值。

还有一个石墨的预览图片是通过 canvas 画上去的,我们这里也会使用 canvasdrawImage 这个 api 来进行图片的绘制,当然在不支持 canvas 的浏览器上,我们就直接使用 <img /> 标签。

在本文就主要分析 canvas 画图这一块内容,<img /> 标签其实也是类似的。

到这里基本上此插件的难点都已经解决了,接下来我们就开始分析相应的代码。

 

代码分析

插件接收参数

首先我们来看一下插件的所需要的参数,大致可以归为下面几个:

  • visible:控制预览插件的显示隐藏
  • imgData:需要预览的图片数组
  • currentImg:再打开预览插件的时候,默认显示第几张图
  • hideModal:预览插件的关闭方法

笔者能想到的暂时就这四个,基本上其实也已经够用了,使用如下:

<PhotoGallery
  visible={visible}
  imgData={ImgData}
  currentImg = {9}
  hideModal={
    () => {
      setVisible(false);
    }
  }
/>

 

插件结构

插件的结构其实很简单,其实就三块:图片显示块图片列表选择侧边栏底部操作块,定义为三个子组件块:分别为 <Canvas /><Sidebar /><Footer /> ,统一由一个父组件管理。

因为我们主要讲解 canvas 画图片,所以图片显示块就设置为 <Canvas />,不支持的 canvas 的浏览器,在源码中会使用 <Image /> 组件来进行图片展示,这里就不做具体介绍了,大家可以参考源码。

父组件代码如下:

// src/components/photoGallery/index.tsx

import React, { useState }  from 'react';
import classNames from 'classnames';
import { Footer, Sidebar, Canvas } from './components';

const photoGallery = (props: Props): JSX.Element => {
  const { imgData, currentImg, visible } = props;
  
  // 当前显示第几张图片
  const [currentImgIndex, setCurrentImgIndex] = useState(currentImg);

  return (
    <div
      className={
        classNames(
          styles.modalWrapper,
          {
            [styles.showImgGallery]: visible, // 根据 visible 渲染插件
          }
        )
      }
    >
      <div className={styles.contentWrapper}>
        <Canvas
          // 要加载的图片 url
          imgUrl={imgUrl}
        />
      </div>
      <Sidebar
        // 图片数组
        imgData={imgData}
      />
      <Footer
        // 图片数量
        imgsLens={imgData.length}
        // 当前第几张
        currentImgIndex={currentImgIndex}
      />
    </div>
  );
}

如上图所示,这样插件的大致的结构就算完成了,接下来就是最核心的图片显示模块的逻辑。

 

图片预览核心逻辑

我们先创建一个类 canvas.ts,对于图片的预览操作,我们都在这个类中进行操作。

这个类接受两个参数,一个是渲染的容器 dom,另外一个就是实例化所需要用到的参数 options,下面是 options 的接口实现:

interface CanvasOptions {
  imgUrl: string; // 图片地址
  winWidth: number; // 屏幕宽度
  winHeight: number; // 屏幕高度
  canUseCanvas: boolean; // 浏览器是否可以使用 canUseCanvas
  loadingComplete?(instance: any): void; // 制作图片 loading 效果
}

还有我们会讲一系列跟预览图片有关的属性都挂在其实例属性上,如:

  • el:渲染的容器
  • canUseCanvas:是否支持 canvas,决定以什么方式画图
  • contextcanvas 的画布 getContext('2d')
  • image:预览图片对象
  • imgUrl:预览图片 url
  • imgTop:图片右上角目标 canvasy 轴的高度
  • imgLeft:图片右上角目标 canvasx 轴的高度
  • LongImgTop:图片距离容器顶部的距离,用于图片滚动和拖动
  • LongImgLeft:图片距离容器左侧的距离,用于图片滚动和拖动
  • sidebarWidth:侧边栏的宽度
  • footerHeight:底部栏的高度
  • cImgWidth:画布中图片的宽度
  • cImgHeight:画布中图片的高度
  • winWidth:屏幕的宽度
  • winHeight:屏幕的高度
  • curPos:鼠标拖动图片是需要用的的 x/y
  • curScaleIndex:当前显示图片,位于尺寸数组中的哪一个 index
  • fixScreenSize:使用屏幕大小的尺寸数组中的 index
  • EachSizeWidthArray:图片的尺寸数组,包含了放大缩小所有尺寸的宽度值
  • isDoCallback:图片是否加载完成

插件中使用的属性值基本上都在上面了。

 

先画一张简单的图

首先我们先来看一下这个 canvas 画图的这个 api,它能帮助我们在画布上绘制图像、画布或视频。

我们可以通过下面的方法放大来帮我们画出一张图片:

var c = document.getElementById("myCanvas");
// 创建画布
var ctx = c.getContext("2d");
// 开始绘制
ctx.drawImage(image, dx, dy, dWidth, dHeight);

其中参数的意思分别为:

  • image:规定要使用的图像、画布或视频。
  • dximage 的左上角在目标 canvasX 轴坐标
  • dyimage的左上角在目标 canvasy 轴坐标
  • dWidthimage 在目标 canvas 上绘制的宽度。
  • dHeightimage 在目标 canvas 上绘制的高度。

具体可以看下图:

关于此方法更多用法大家可以参考:drawImage 的 MDN 文档

有了这个 api 之后,我们其实只要计算出这个 api 对应的 5 个参数即可,举个简单的例子,下面这张图我们改怎么得到 5 个参数:

  • image 对象

我们可以使用 new Image() 来实例化一个 image 对象,并指定他的 src 属性为相应的图片 url 地址,这样就可以得到一个 image 对象,当图片加载完成之后,我们就可以通过 imgDom.naturalWidthimgDom.naturalHeight 图片的原始宽高:

// src/components/photoGallery/canvas.ts

loadimg(imgurl) {
  const imgDom = new Image();
  imgDom.src = imgUrl;

  imgDom.onload = function() {
    // 图片加载完成之后
    // 做你想要做的事情
  }
}
  • dxdydwidthdHeight 属性

我们以长图举例:我们在讲解思路的时候分析过,上下两边留空的部分是 图片显示容器高度5%,在这里我们定义了底部块的高度(footerHeight)为 50px,侧边栏的宽度(sidebarWidth)为 120px,这就变成了一道小学应用题,我们可以通过 window.innerWidthwindow.innerHeight 来得到屏幕的宽(winWidth)和高(winHeight),经过计算我们便可以得到我们所需的四个属性:

/**
 * winWidth:屏幕宽度
 * winHeight:屏幕高度
 * footerHeight:底部高度
 * sidebarWidth:侧边栏宽度
 * wrapperWidth:图片显示区域宽度
 * wrapperHeight:图片显示区域高度
 * naturalWidth: 图片原始宽度
 * naturalHeight: 图片原始高度
 */

wrapperHeight = winHeight - footerHeight;
wrapperWidth = winWidth - sidebarWidth;

dy = wrapperHeight * 0.05;
dHeight = wrapperHeight - 2 * dy;

// 与原始宽高有个等比例的关系
dWidth = naturalWidth * dHeight / naturalHeight;
dx = (wrapperWidth - dWidth) / 2

上面就是计算我们所需五个属性的过程,总的来说还是比较方便的。

所以在我们每次要绘制图片的时候,只要计算出这 5 个值就 ok 了。

 

初始图片宽高

我们在 utils 下的 img.ts 中定义一个方法 getBoundingClientRect,用来得到 图片的显示宽高和他距离容器顶部的 imgTop、以及距离左侧的 imgLeft

// src/utils/img.ts
/**
 * 返回第一次加载图片的宽高,和 imgTop/imgLeft
 * 通过返回的参数 直接 通过 drawImage 画图了
 **/
export const getBoundingClientRect = (options: RectWidth): BoundingClientRect => {
  const {
    naturalWidth, // 图片原始宽
    naturalHeight, // 图片原始高
    wrapperWidth, // 显示容器宽
    wrapperHeight, // 显示容器高
    winWidth, // 屏幕宽度
  } = options;

  // 图片宽高比
  const imageRadio = naturalWidth / naturalHeight;
  
  // 显示容器宽高比
  const wrapperRadio = wrapperWidth / wrapperHeight;

  // 长图的逻辑
  if (imageRadio <= 1) {
    // 具体画布上方默认是 容器高度的 0.05
    imgTop = wrapperHeight * 0.05;

    // 图片的高度
    ImgHeight = wrapperHeight - wrapperHeight * 0.05 * 2;
    // 根据原始宽高,等比例得到图片宽度
    ImgWidth = ImgHeight * naturalWidth / naturalHeight;

    // 如果图片的宽高比显示容器的宽高比大
    // 说明图片左右两侧的宽度需要固定为容器的宽度的 0.05 倍了
    if (wrapperRadio <= imageRadio) {
      ImgWidth = wrapperWidth - wrapperWidth * 0.05 * 2;
      ImgHeight =  ImgWidth * naturalHeight / naturalWidth;

      imgTop = (wrapperHeight - ImgHeight) / 2
    }

    // ...
    imgLeft = newWinWidth - ImgWidth / 2;
  }

  // 处理宽图的逻辑
  // ...

  // 返回
  return {
    imgLeft,
    imgTop,
    ImgWidth,
    ImgHeight,
  }
}

更详细的代码大家可以参考源码。

 

预览图片尺寸数组

我们在之前提到,我们可以把图片放大缩小过程中所有的尺寸都放到一个数组中去,方便之后通过索引去得到相应的图片尺寸,那么怎么进行操作呢?

其实只要在图片加载完成之后,得到图片的原始宽高,通过原始宽高,通过相应的计算公式,计算得到相应的尺寸数组,塞入数组即可。

在类中定义一个 setEachSizeArr 实例方法:

// src/components/photoGallery/canvas.ts
/**
 * 计算图片放大、缩小各尺寸的大小数组,
 */
private setEachSizeArr () {
  const image = this.image;
  
  // 得到尺寸数组
  const EachSizeWidthArray: number[] = getEachSizeWidthArray({
    naturalWidth: image.width,
    naturalHeight: image.height,
  })

  // 挂到实例属性上去
  this.EachSizeWidthArray = EachSizeWidthArray;

  // 得到适应屏幕的 index
  // 也就是操作按钮中的 第四个按钮
  const fixScreenSize = getFixScreenIndex({
    naturalWidth: image.width,
    naturalHeight: image.height,
    wrapperWidth: this.cWidth,
    wrapperHeight: this.cHeight,
  }, EachSizeWidthArray);

  // 将适应屏幕的 index 挂到实例属性
  this.fixScreenSize = fixScreenSize;
}
  • getEachSizeWidthArray

我们通过此方法得到尺寸数组,因为最大的图片是原图的 4 倍,最小的图片是原图的 1/10,从最小到原图 和 从原图到最大 都需要经过 6 次,我们可以根据比例得出每一个尺寸的大小,具体的代码笔者就不贴了。

  • getFixScreenIndex

我们通过此方法得到适应屏幕的尺寸数组的 index,原理就是在尺寸数组中第一个宽高小于显示容器宽高的 index

这两个方法的具体代码笔者就不贴了,大家有兴趣可以去源码查看。

 

初始预览图片索引

我们要计算出首次图片渲染出来时候,位于尺寸数组的那一个 index,因为我们得到首次渲染图片的宽度,可以拿这个宽度去与尺寸数组中数组进行比对,最接近的这个值的索引 index,就是当前图片的 index 值:

// src/components/photoGallery/canvas.ts
/**
 * 设置当前 EachSizeWidthArray 的索引,用于 放大缩小
 */
private setCurScaleIndex() {
  const cImgWidth = this.cImgWidth || this.image.width;

  const EachSizeWidthArray = this.EachSizeWidthArray;

  const curScaleIndex = getCurImgIndex(EachSizeWidthArray, cImgWidth);

  this.curScaleIndex = curScaleIndex;
}
  • getCurImgIndex

我们通过此方法来得到当前图片款的索引值,他是根据当前渲染的图片宽度,去 尺寸数组 取出最接近预览图片宽度,从而得到当前图片的 index,具体实现大家可以参考源码。

 

放大缩小逻辑

放大预览的逻辑实际上就是根据放大之后的尺寸,计算出当前图片的距离 canvas 顶部的高度 imgTop、以及距离左侧 canvasimgLeft

前面我们已经得到首次图片展示索引了,当我们点击放大的时候,无非就是将当前索引值加一,缩小就是减一。

我们可以根据新的索引值去 尺寸数组 中取出对应索引的宽度,通过图片原始宽高,可以等比例得到当前应该显示的宽高,最后我们只需要计算出,放大后的图片的 imgTopimgLeft 的值,其实就能实现功能了:

/**
 * 修改当前 图片大小数组中的 索引
 * @param curSizeIndex :  
 */
public changeCurSizeIndex(curSizeIndex: number) {
  let curScaleIndex = curSizeIndex;

  if (curScaleIndex > 12) curScaleIndex = 12;
  if (curScaleIndex < 0) curScaleIndex = 0;

  // 画布宽高,即显示容器宽高
  const cWidth = this.cWidth;
  const cHeight = this.cHeight;

  // 上一次的索引
  const prevScaleTimes = this.curScaleIndex;
    // 尺寸数组
  const EachSizeWidthArray = this.EachSizeWidthArray;

  let scaleRadio = 1;

    // 这一次宽度与上一次的比值
  // 通过这个值能更方便的得到图片宽高
  scaleRadio = EachSizeWidthArray[curScaleIndex] / EachSizeWidthArray[prevScaleTimes];

  // 当前图片宽高
  this.cImgHeight = this.cImgHeight * scaleRadio;
  this.cImgWidth = this.cImgWidth * scaleRadio;

  // 得到最新的 imgTop
  // imgTop 值正负值是根据画布左上角的点,向下为正
  this.imgTop = cHeight / 2 - (cHeight / 2 - this.imgTop) * scaleRadio;
  // 设置当前 索引值
  this.curScaleIndex = curScaleIndex;

  // 如果图片没有超过画布的宽和高
  if (this.cImgHeight < cHeight && this.cImgWidth < cWidth) {
    this.imgTop = (cHeight - this.cImgHeight) / 2;
  }

  // imgLeft 的计算
  this.imgLeft = cWidth / 2 - this.cImgWidth / 2;

  // 在图片滑动的时候或者拖动的时候需要用到
  this.LongImgTop = this.imgTop;
  this.LongImgLeft = this.imgLeft;

  // 绘制图片
  // ...
}

 

事件

滚动事件

canvas 中进行图片滚动,其实就是重新计算图片的 imgTopimgLeft,然后对其进行重新绘制。

这里我们使用滚轮事件 onWheel 来计算滚动的距离 ,通过事件对象 event 上的 deltaXdeltaY 得到的在 x/y 轴上的滚动距离。

这里需要注意的一个点是对边界值的处理,imgTop 不能无止境的大和小,其最大不能超过我们之前规定的 LONG_IMG_TOP 这个值,我们设置的是 10px,最小可以参照下面的计算方式(宽度的边界值计算类似,就不做介绍了)

/**
 * minImgTop:最小的 imgTop 值
 * maxImgTop:最大的 imgTop 值
 * imgHeight:图片高度
 * winHeight:屏幕高度
 * footerHeight:底部操作栏高度
 * LONG_IMG_TOP:我们设置的一个上下常量 padding
 */
// 最小肯定是负数
minImgTop = -(imgHeight - (winHeight - footerHeight - LONG_IMG_TOP))
// 最大
maxImgTop = LONG_IMG_TOP

接下来我们在 canvas 类中定义一个 WheelUpdate 事例方法,暴露出去给外部调用,

// src/components/photoGallery/canvas.ts

/**
 * 滚轮事件
 * @param e wheel 的事件参数
 */
public WheelUpdate(e: any) {
    // ...

  // 图片显示容器的宽高
  const cWidth = this.cWidth;
  const cHeight = this.cHeight;

  // 如果图片的宽高都小于图片显示容器的宽高就直接返回
  if (this.cImgHeight < cHeight && this.cImgWidth < cWidth) {
    return;
  }

  // 如果图片的高度 大于 显示容器的 高度
  // 则允许 在 Y 方向上 滑动
  if (this.cImgHeight > cHeight) {
    // 此值保存当前图片距离容器 imgTop
    this.LongImgTop = this.LongImgTop - e.deltaY;

    // e.deltaY 向下
    if (e.deltaY > 0) {
      // 这里做一个极限值的判断
      // 具体是我们的算法
      if ((-this.LongImgTop) > this.cImgHeight + LONG_IMG_TOP - window.innerHeight + this.footerHeight) {
        this.LongImgTop = -(this.cImgHeight + LONG_IMG_TOP - window.innerHeight + this.footerHeight);
      }
    } else {
      // 往上滑的时候,最大值是兼容值 LONG_IMG_TOP
      if (this.LongImgTop > LONG_IMG_TOP) {
        this.LongImgTop = LONG_IMG_TOP;
      }
    }
  }

  // 处理 x 轴上的滚动 
  // ...

  // 赋值 imgTop,imgLeft
  this.imgTop = this.LongImgTop;
  this.imgLeft = this.LongImgLeft;

  // 绘制图片
  // ...
}

 

拖动事件

图片拖动的我们需要借助 onMouseDownonMouseMoveonMouseUp 三个事件函数。其实操作方式可图片滚动类似,我们需要计算出新的 imgTopimgLeft 去重新绘制图片,但是我们不能通过 event 下面直接得到拖动的值了,需要通过后一次与前一次的差值,来得出拖动的距离,进而计算出 imgTopimgLeft 值,

首先我们把图片拖动过程中的实时坐标挂在实例属性 curPos 上,在 onMouseDown 的时候进行初始坐标赋值,这样在 onMouseMove 函数中我们就能得到鼠标按下的初始坐标了。

// src/components/photoGallery/index.tsx

/**
 * 鼠标按下事件
 * @param e
 * @param instance : 图片预览的实例
 */
const MouseDown = (e: any, instance: any) => {
  // 全局 moveFlag 表示拖动是否开始
  moveFlag = true;
  const { clientX, clientY } = e;

  // 给当前预览实例设置初始 x、y 坐标
  instance.curPos.x = clientX;
  instance.curPos.y = clientY;

  // ...
};

/**
 * 鼠标抬起事件
 */
const MouseUp = (e: any) => {
  moveFlag = false;
};

/**
 * 鼠标移动事件
 */
const MouseMove = useCallback((e: any, instance: any) => {
  // 直接调用实例下的 MoveCanvas 方法
  instance.MoveCanvas(moveFlag, e);
}, [])

接下来我们看一下最主要的拖动方法 MoveCanvas,我们通过实时的坐标值减去上一次的坐标值(curPos 保存的值)做比较,得出滑动的距离,这样我们便能得出最新的 imgTopimgLeft 值了,当然这里也不要忘记对边界值的计算。

// src/components/photoGallery/canvas.ts

/**
 * 鼠标拖动的事件
 * @param moveFlag : 是否能移动的标志位
 * @param e
 */
public MoveCanvas(moveFlag: boolean, e: any) {
  // 在拖动情况下才执行拖动逻辑
  if (moveFlag) {
    // 图片显示容器的宽高
    const cWidth = this.cWidth;
    const cHeight = this.cHeight;
        
    if (this.cImgHeight < cHeight && this.cImgWidth < cWidth) {
      return;
    }

    // 当前滑动的坐标
    const { clientX, clientY } = e;

    // 上一次坐标
    const curX = this.curPos.x;
    const curY = this.curPos.y;

    // 处理 Y 轴上的滚动 
    if (this.cImgHeight > this.cHeight) {
      // 此值保存当前图片距离容器 imgTop
      this.LongImgTop = this.LongImgTop + (clientY - this.curPos.y);
      // 与滚动类似的边界值计算
    }

    // 处理 x 轴上的滚动 
    // ...

    // 更新实例属性上的 x、y 值
    this.curPos.x = clientX;
    this.curPos.y = clientY;

    // 赋值 imgTop,imgLeft
    this.imgTop = this.LongImgTop;
    this.imgLeft = this.LongImgLeft;

        // 绘制图片
    // ...
  }
}

 

预览插件关闭

我们在点击图片的时候去关闭图片预览插件,不过这里需要考虑的是,我们能够拖动图片,当用户是拖动图片的时候,我们就不需要关闭插件,所以我们就需要判断用户鼠标按下之前和之后, x/y 坐标值有没有发生过改变,如果发生过改变了,那我们就不执行关闭操作,否则直接将预览插件直接关闭。

因为 mosueDownmouseUp 事件是要早于 click 事件的,我们设置一个标志位 DoClick,如果鼠标按下前后位置没变的话,此标志位就为 true,那么当图片点击的时候,就直接进行关闭,反之就不处理。

// src/components/photoGallery/index.tsx

const MouseDown = (e: any, instance: any) => {
  // ...
  StartPos.x = clientX;
  StartPos.y = clientY;
}

const MouseUp = (e: any) => {
  if (e.clientX === StartPos.x && e.clientY === StartPos.y) {
    DoClick = true;
  } else {
    DoClick = false;
  }
}

const Click = () => {
  if (!DoClick) return;
  
  const { hideModal } = props;
  if (hideModal) {
    hideModal();
  }
}

 

其他知识点

图片类何时实例化

我们之前创建了一个预览图片的类,那么具体需要在什么时候去实例化呢?

只需要监听在传入的 imgUrl 变化的时候,就去把之前的实例清空,同时新实例化一个插件就 ok 了。

// src/components/photoGallery/components/Canvas.tsx

const Canvas = (props: Props): JSX.Element => {
  // ...
  // canvas 的 dom 元素
  let canvasRef: any = useRef();
  // 存放预览图片实例的变量
  let canvasInstance: any = useRef(null);

  useEffect((): void => {
    if (canvasInstance.current) canvasInstance.current = null;

    const canvasNode = canvasRef.current;

    canvasInstance.current = new ImgToCanvas(canvasNode, {
      imgUrl,
      winWidth,
      winHeight,
      canUseCanvas,
      // 图片加载完成钩子
      loadingComplete: function(instance) {
        props.setImgLoading(false);
        props.setCurSize(instance.curScaleIndex);
        props.setFixScreenSize(instance.fixScreenSize);
      },
    });
  }, [imgUrl]);
  
  // ...
}

有了这个图片实例 canvasInstance,对于这张预览图的各种操作,比如 放大缩小 我们都可以调用其拥有的方法就可以简单实现了。

 

屏幕尺寸

当我们在屏幕尺寸变化的时候,需要根据最新的尺寸去实时绘制图片,这里我们写了一个自定义 Hooks,监听屏幕 size 的变化。

// src/components/photoGallery/index.tsx

function useWinSize(){
  const [ size , setSize] = useState({
    width:  document.documentElement.clientWidth,
    height: document.documentElement.clientHeight
  });
  
  const onResize = useCallback(()=>{
    setSize({
      width: document.documentElement.clientWidth,
      height: document.documentElement.clientHeight,
    })
  }, []);

  useEffect(()=>{
    window.addEventListener('resize', onResize, false);

    return ()=>{
      window.removeEventListener('resize', onResize, false);
    }
  }, [])

  return size;
}

 

canvas 绘制闪烁

还有一个问题就在 canvas 绘制过程中,当屏幕 resize 的过程中会出现闪烁的问题,如下图:

这是因为重绘画布的时候,我们需要使用 clearRect 来清空画布,此时的画布是空的,开始重绘需要相应的时间,因此在视觉会出现闪屏的情况。解决闪屏,其实就是怎么解决绘制时间较长的问题

我们可以参考 双缓存 的概念来解决这个问题,将绘制过程交给了 缓存 canvas,这样页面中的 canvas 就省去了绘制过程,而 缓存 canvas 并没有添加到页面,所以我们就看不到绘制过程,在 缓存 canvas 绘制好之后,直接将其赋给页面原来的 canvas 这样就解决了闪屏的问题。

// src/components/photoGallery/canvas.ts

class ImgToCanvas {
  // ...
  private cacheCanvas : any;
  private context : any;
  
  // ...
  
  private drawImg (type?: string) {
    // 页面中 canvas
    const context = this.context;
    // ...
    
    // 创建一个 缓存 canvas,并挂到实例属性 cacheCanvas 下
    if (!this.cacheCanvas) {
      this.cacheCanvas = document.createElement("canvas");
    }

    // 设置 缓存 canvas 的宽高
    this.cacheCanvas.width = this.cWidth;
    this.cacheCanvas.height = this.cHeight;
    // 创建画布
    const tempCtx = this.cacheCanvas.getContext('2d')!;

    // 使用 缓存 canvas 画图
    tempCtx.drawImage(image, this.imgLeft, this.imgTop, this.cImgWidth, this.cImgHeight);

    // 清除画布,并将缓存 canvas 赋给 页面 canvas
    requestAnimationFrame(() => {
      this.clearLastCanvas(context);
      context.drawImage(this.cacheCanvas, 0, 0);
    })
    
    // ...
  }
}

 

小结

这篇文章整理了一个仿水墨图片预览插件从零到一的实现过程,从 思路分析代码结构划分主要逻辑的实现 这几个方面阐述了一波。

通过这个插件的编写,笔者对于 canvas 的画图 api、如何处理 canvas 绘图过程中出现的图片闪烁的问题,以及对于 React Hooks 的一些用法有了大致的了解。

实不相瞒,想要个赞!

 

参考内容

查看原文

赞 2 收藏 1 评论 0

Darrell 关注了专栏 · 5月6日

民工哥技术之路

公众号:民工哥技术之路、《Linux系统运维指南 从入门到企业实战》作者。专注系统架构、高可用、高性能、高并发,数据库、大数据、数据分析、Python技术、集群中间件、后端等开源技术分享。

关注 16079

Darrell 关注了用户 · 5月6日

gxcuizy @cuizhongyi

我是如此的平凡,
却又如此的幸运。

关注 130

Darrell 关注了用户 · 5月6日

日拱一兵 @tanrigongyibing

欢迎关注,公众号「日拱一兵」,以读侦探小说思维趣味轻松学习Java技术

送你《1000G 免费精选技术学习资料》(2020 年最新)
https://mp.weixin.qq.com/s/9p...

关注 19390

Darrell 关注了专栏 · 5月6日

full stack dev stills

back-end: Nodejs front-end: iOS, Android, Js

关注 1676

Darrell 关注了专栏 · 5月6日

felix021

这个人很懒,什么都没留下。

关注 853

认证与成就

  • 获得 5 次点赞
  • 获得 0 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 0 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-11-02
个人主页被 155 人浏览