独钓寒江雪

独钓寒江雪 查看完整档案

上海编辑南京航空航天大学  |  信息与计算科学 编辑UCloud  |  前端研发攻城狮 编辑 king-hcj.github.io 编辑
编辑

🦅 2021/1/11 孤篷 更名 独钓寒江雪
🐦 思否2019年度有奖征文 文采三剑客
👑 Nothing is given, Everything is earned!

个人动态

独钓寒江雪 发布了文章 · 1月11日

从打字机效果的 N 种实现看JS定时器机制和前端动画

  首先,什么是打字机效果呢?打字机效果即为文字逐个输出,实际上就是一种Web动画。一图胜千言,诸君请看:

Typed.js

  在Web应用中,实现动画效果的方法比较多,JavaScript 中可以通过定时器 setTimeout 来实现,css3 可以使用 transition 和 animation 来实现,html5 中的 canvas 也可以实现。除此之外,html5 还提供一个专门用于请求动画的 API,即 requestAnimationFrame(rAF),顾名思义就是 “请求动画帧”。接下来,我们一起来看看 打字机效果 的几种实现。为了便于理解,我会尽量使用简洁的方式进行实现,有兴趣的话,你也可以把这些实现改造的更有逼格、更具艺术气息一点,因为编程,本来就是一门艺术。

打字机效果的 N 种实现

实现一:setTimeout()

  setTimeout版本的实现很简单,只需把要展示的文本进行切割,使用定时器不断向DOM元素里追加文字即可,同时,使用::after伪元素在DOM元素后面产生光标闪烁的效果。代码和效果图如下:

<!-- 样式 -->
<style type="text/css">
  /* 设置容器样式 */
  #content {
    height: 400px;
    padding: 10px;
    font-size: 28px;
    border-radius: 20px;
    background-color: antiquewhite;
  }
  /* 产生光标闪烁的效果 */
  #content::after{
      content: '|';
      color:darkgray;
      animation: blink 1s infinite;
  }
  @keyframes blink{
      from{
          opacity: 0;
      }
      to{
          opacity: 1;
      }
  }
</style>

<body>
  <div id='content'></div>
  <script>
    (function () {
    // 获取容器
    const container = document.getElementById('content')
    // 把需要展示的全部文字进行切割
    const data = '最简单的打字机效果实现'.split('')
    // 需要追加到容器中的文字下标
    let index = 0
    function writing() {
      if (index < data.length) {
        // 追加文字
        container.innerHTML += data[index ++]
        let timer = setTimeout(writing, 200)
        console.log(timer) // 这里会依次打印 1 2 3 4 5 6 7 8 9 10
      }
    }
    writing()
  })();
  </script>
</body>

Typed1

  setTimeout()方法的返回值是一个唯一的数值(ID),上面的代码中,我们也做了setTimeout()返回值的打印,那么,这个数值有什么用呢?
  如果你想要终止setTimeout()方法的执行,那就必须使用 clearTimeout()方法来终止,而使用这个方法的时候,系统必须知道你到底要终止的是哪一个setTimeout()方法(因为你可能同时调用了好几个 setTimeout()方法),这样clearTimeout()方法就需要一个参数,这个参数就是setTimeout()方法的返回值(数值),用这个数值来唯一确定结束哪一个setTimeout()方法。

实现二:setInterval()

  setInterval实现的打字机效果,其实在MDN window.setInterval 案例三中已经有一个了,而且还实现了播放、暂停以及终止的控制,效果可点击这里查看,在此只进行setInterval打字机效果的一个最简单实现,其实代码和前文setTimeout的实现类似,效果也一致。

(function () {
  // 获取容器
  const container = document.getElementById('content')
  // 把需要展示的全部文字进行切割
  const data = '最简单的打字机效果实现'.split('')
  // 需要追加到容器中的文字下标
  let index = 0
  let timer = null
  function writing() {
    if (index < data.length) {
      // 追加文字
      container.innerHTML += data[index ++]
      // 没错,也可以通过,clearTimeout取消setInterval的执行
      // index === 4 && clearTimeout(timer)
    } else {
      clearInterval(timer)
    }
    console.log(timer) // 这里会打印出 1 1 1 1 1 ...
  }
  // 使用 setInterval 时,结束后不要忘记进行 clearInterval
  timer = setInterval(writing, 200)
})();

  和setTimeout一样,setInterval也会返回一个 ID(数字),可以将这个ID传递给clearInterval()或者clearTimeout() 以取消定时器的执行

  在此有必要强调一点:定时器指定的时间间隔,表示的是何时将定时器的代码添加到消息队列,而不是何时执行代码。所以真正何时执行代码的时间是不能保证的,取决于何时被主线程的事件循环取到,并执行。

实现三:requestAnimationFrame()

  在动画的实现上,requestAnimationFrame 比起 setTimeout 和 setInterval来无疑更具优势。我们先看看打字机效果的requestAnimationFrame实现:

(function () {
    const container = document.getElementById('content')
    const data = '与 setTimeout 相比,requestAnimationFrame 最大的优势是 由系统来决定回调函数的执行时机。具体一点讲就是,系统每次绘制之前会主动调用 requestAnimationFrame 中的回调函数,如果系统绘制率是 60Hz,那么回调函数就每16.7ms 被执行一次,如果绘制频率是75Hz,那么这个间隔时间就变成了 1000/75=13.3ms。换句话说就是,requestAnimationFrame 的执行步伐跟着系统的绘制频率走。它能保证回调函数在屏幕每一次的绘制间隔中只被执行一次,这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题。'.split('')
    let index = 0
    function writing() {
      if (index < data.length) {
        container.innerHTML += data[index ++]
        requestAnimationFrame(writing)
      }
    }
    writing()
  })();

Typed2

  与setTimeout相比,requestAnimationFrame最大的优势是由系统来决定回调函数的执行时机。具体一点讲,如果屏幕刷新率是60Hz,那么回调函数就每16.7ms被执行一次,如果刷新率是75Hz,那么这个时间间隔就变成了1000/75=13.3ms,换句话说就是,requestAnimationFrame的步伐跟着系统的刷新步伐走。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题

实现四:CSS3

  除了以上三种JS方法之外,其实只用CSS我们也可以实现打字机效果。大概思路是借助CSS3的@keyframes来不断改变包含文字的容器的宽度,超出容器部分的文字隐藏不展示。

<style>
  div {
    font-size: 20px;
    /* 初始宽度为0 */
    width: 0;
    height: 30px;
    border-right: 1px solid darkgray;
    /*
    Steps(<number_of_steps>,<direction>)
    steps接收两个参数:第一个参数指定动画分割的段数;第二个参数可选,接受 start和 end两个值,指定在每个间隔的起点或是终点发生阶跃变化,默认为 end。
    */
    animation: write 4s steps(14) forwards,
      blink 0.5s steps(1) infinite;
      overflow: hidden;
  }

  @keyframes write {
    0% {
      width: 0;
    }

    100% {
      width: 280px;
    }
  }

  @keyframes blink {
    50% {
      /* transparent是全透明黑色(black)的速记法,即一个类似rgba(0,0,0,0)这样的值。 */
      border-color: transparent; /* #00000000 */
    }
  }
</style>

<body>
  <div>
    大江东去浪淘尽,千古风流人物
  </div>
</body>

Typed3

  以上CSS打字机效果的原理一目了然:

  • 初始文字是全部在页面上的,只是容器的宽度为0,设置文字超出部分隐藏,然后不断改变容器的宽度;
  • 设置border-right,并在关键帧上改变 border-colortransparent,右边框就像闪烁的光标了。

实现五:Typed.js

Typed.js is a library that types. Enter in any string, and watch it type at the speed you've set, backspace what it's typed, and begin a new sentence for however many strings you've set.

  Typed.js是一个轻量级的打字动画库, 只需要几行代码,就可以在项目中实现炫酷的打字机效果(本文第一张动图即为Typed.js实现)。源码也相对比较简单,有兴趣的话,可以到GitHub进行研读

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script data-original="https://cdn.jsdelivr.net/npm/typed.js@2.0.11"></script>
</head>

<body>
  <div id="typed-strings">
    <p>Typed.js is a <strong>JavaScript</strong> library.</p>
    <p>It <em>types</em> out sentences.</p>
  </div>
  <span id="typed"></span>
</body>
<script>
  var typed = new Typed('#typed', {
    stringsElement: '#typed-strings',
    typeSpeed: 60
  });
</script>

</html>

Typed4

  使用Typed.js,我们也可以很容易的实现对动画开始、暂停等的控制:

<body>
  <input type="text" class="content" name="" style="width: 80%;">
  <br>
  <br>
  <button class="start">开始</button>
  <button class="stop">暂停</button>
  <button class="toggle">切换</button>
  <button class="reset">重置</button>
</body>
<script>
const startBtn = document.querySelector('.start');
const stopBtn = document.querySelector('.stop');
const toggleBtn = document.querySelector('.toggle');
const resetBtn = document.querySelector('.reset');
const typed = new Typed('.content',{
  strings: ['雨过白鹭州,留恋铜雀楼,斜阳染幽草,几度飞红,摇曳了江上远帆,回望灯如花,未语人先羞。'],
  typeSpeed: 200,
  startDelay: 100,
  loop: true,
  loopCount: Infinity,
  bindInputFocusEvents:true
});
startBtn.onclick = function () {
  typed.start();
}
stopBtn.onclick = function () {
  typed.stop();
}
toggleBtn.onclick = function () {
  typed.toggle();
}
resetBtn.onclick = function () {
  typed.reset();
}
</script>

Typed5

参考资料:Typed.js官网 | Typed.js GitHub地址

  当然,打字机效果的实现方式,也不仅仅局限于上面所说的几种方法,本文的目的,也不在于搜罗所有打字机效果的实现,如果那样将毫无意义,接下来,我们将会对CSS3动画和JS动画进行一些比较,并对setTimeout、setInterval 和 requestAnimationFrame的一些细节进行总结。

CSS3动画和JS动画的比较

  关于CSS动画和JS动画,有一种说法是CSS动画比JS流畅,其实这种流畅是有前提的。借此机会,我们对CSS3动画和JS动画进行一个简单对比。

JS动画

  • 优点:

    • JS动画控制能力强,可以在动画播放过程中对动画进行精细控制,如开始、暂停、终止、取消等;
    • JS动画效果比CSS3动画丰富,功能涵盖面广,比如可以实现曲线运动、冲击闪烁、视差滚动等CSS难以实现的效果;
    • JS动画大多数情况下没有兼容性问题,而CSS3动画有兼容性问题;
  • 缺点:

    • JS在浏览器的主线程中运行,而主线程中还有其它需要运行的JS脚本、样式计算、布局、绘制任务等,对其干扰可能导致线程出现阻塞,从而造成丢帧的情况;
    • 对于帧速表现不好的低版本浏览器,CSS3可以做到自然降级,而JS则需要撰写额外代码;
    • JS动画往往需要频繁操作DOM的css属性来实现视觉上的动画效果,这个时候浏览器要不停地执行重绘和重排,这对于性能的消耗是很大的,尤其是在分配给浏览器的内存没那么宽裕的移动端。

CSS3动画

  • 优点:

    • 部分情况下浏览器可以对动画进行优化(比如专门新建一个图层用来跑动画),为什么说部分情况下呢,因为是有条件的:

      • 在Chromium基础上的浏览器中
      • 同时CSS动画不触发layout或paint,在CSS动画或JS动画触发了paint或layout时,需要main thread进行Layer树的重计算,这时CSS动画或JS动画都会阻塞后续操作。
    • 部分效果可以强制使用硬件加速 (通过 GPU 来提高动画性能)
  • 缺点:

    • 代码冗长。CSS 实现稍微复杂一点动画,CSS代码可能都会变得非常笨重;
    • 运行过程控制较弱。css3动画只能在某些场景下控制动画的暂停与继续,不能在特定的位置添加回调函数。

main thread(主线程)和compositor thread(合成器线程)

  • 渲染线程分为main thread(主线程)和compositor thread(合成器线程)。主线程中维护了一棵Layer树(LayerTreeHost),管理了TiledLayer,在compositor thread,维护了同样一颗LayerTreeHostImpl,管理了LayerImpl,这两棵树的内容是拷贝关系。因此可以彼此不干扰,当Javascript在main thread操作LayerTreeHost的同时,compositor thread可以用LayerTreeHostImpl做渲染。当Javascript繁忙导致主线程卡住时,合成到屏幕的过程也是流畅的。
  • 为了实现防假死,鼠标键盘消息会被首先分发到compositor thread,然后再到main thread。这样,当main thread繁忙时,compositor thread还是能够响应一部分消息,例如,鼠标滚动时,如果main thread繁忙,compositor thread也会处理滚动消息,滚动已经被提交的页面部分(未被提交的部分将被刷白)。

CSS动画比JS动画流畅的前提

  • CSS动画比较少或者不触发pain和layout,即重绘和重排时。例如通过改变如下属性生成的css动画,这时整个CSS动画得以在compositor thread完成(而JS动画则会在main thread执行,然后触发compositor进行下一步操作):

    • backface-visibility:该属性指定当元素背面朝向观察者时是否可见(3D,实验中的功能);
    • opacity:设置 div 元素的不透明级别;
    • perspective 设置元素视图,该属性只影响 3D 转换元素;
    • perspective-origin:该属性允许您改变 3D 元素的底部位置;
    • transform:该属性应用于元素的2D或3D转换。这个属性允许你将元素旋转,缩放,移动,倾斜等。
  • JS在执行一些昂贵的任务时,main thread繁忙,CSS动画由于使用了compositor thread可以保持流畅;
  • 部分属性能够启动3D加速和GPU硬件加速,例如使用transform的translateZ进行3D变换时;
  • 通过设置 will-change 属性,浏览器就可以提前知道哪些元素的属性将会改变,提前做好准备。待需要改变元素的时机到来时,就可以立刻实现它们,从而避免卡顿等问题。

    • 不要将 will-change 应用到太多元素上,如果过度使用的话,可能导致页面响应缓慢或者消耗非常多的资源。
    • 例如下面的代码就是提前告诉渲染引擎 box 元素将要做几何变换和透明度变换操作,这时候渲染引擎会将该元素单独实现一帧,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。

      .box {will-change: transform, opacity;}

setTimeout、setInterval 和 requestAnimationFrame 的一些细节

setTimeout 和 setInterval

  • setTimeout 的执行时间并不是确定的。在JavaScript中,setTimeout 任务被放进了异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列里的任务是否需要开始执行,所以 setTimeout 的实际执行时机一般要比其设定的时间晚一些。
  • 刷新频率受 屏幕分辨率 和 屏幕尺寸 的影响,不同设备的屏幕绘制频率可能会不同,而 setTimeout 只能设置一个固定的时间间隔,这个时间不一定和屏幕的刷新时间相同。
  • setTimeout 的执行只是在内存中对元素属性进行改变,这个变化必须要等到屏幕下次绘制时才会被更新到屏幕上。如果两者的步调不一致,就可能会导致中间某一帧的操作被跨越过去,而直接更新下一帧的元素。假设屏幕每隔16.7ms刷新一次,而setTimeout 每隔10ms设置图像向左移动1px, 就会出现如下绘制过程:

    • 第 0 ms:屏幕未绘制,等待中,setTimeout 也未执行,等待中;
    • 第 10 ms:屏幕未绘制,等待中,setTimeout 开始执行并设置元素属性 left=1px;
    • 第 16.7 ms:屏幕开始绘制,屏幕上的元素向左移动了 1px, setTimeout 未执行,继续等待中;
    • 第 20 ms:屏幕未绘制,等待中,setTimeout 开始执行并设置 left=2px;
    • 第 30 ms:屏幕未绘制,等待中,setTimeout 开始执行并设置 left=3px;
    • 第 33.4 ms:屏幕开始绘制,屏幕上的元素向左移动了 3px, setTimeout 未执行,继续等待中;
    • ...

  从上面的绘制过程中可以看出,屏幕没有更新 left=2px 的那一帧画面,元素直接从left=1px 的位置跳到了 left=3px 的的位置,这就是丢帧现象,这种现象就会引起动画卡顿。

  • setInterval的回调函数调用之间的实际延迟小于代码中设置的延迟,因为回调函数执行所需的时间“消耗”了间隔的一部分,如果回调函数执行时间长、执行次数多的话,误差也会越来越大
// repeat with the interval of 2 seconds
let timerId = setInterval(() => console.log('tick', timerId), 2000);
// after 50 seconds stop
setTimeout(() => {
  clearInterval(timerId);
  console.log('stop', timerId);
}, 50000);

setInterval

  • 嵌套的setTimeout可以保证固定的延迟:
let timerId = setTimeout(function tick() {
  console.log('tick', timerId);
  timerId = setTimeout(tick, 2000); // (*)
}, 2000);

setTimeout

requestAnimationFrame

  除了上文提到的requestAnimationFrame的优势外,requestAnimationFrame还有以下两个优势:

  • CPU节能:使用setTimeout实现的动画,当页面被隐藏或最小化时,setTimeout 仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,完全是浪费CPU资源。而requestAnimationFrame则完全不同,当页面处于未激活的状态下,该页面的屏幕刷新任务也会被系统暂停,因此跟着系统步伐走的requestAnimationFrame也会停止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了CPU开销。
  • 函数节流:在高频率事件(resize,scroll等)中,为了防止在一个刷新间隔内发生多次函数执行,使用requestAnimationFrame可保证每个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销。一个刷新间隔内函数执行多次是没有意义的,因为显示器每16.7ms刷新一次,多次绘制并不会在屏幕上体现出来。

关于最小时间间隔

  • 2011年的标准中是这么规定的:

    • setTimeout:如果当前正在运行的任务是由setTimeout()方法创建的任务,并且时间间隔小于4ms,则将时间间隔增加到4ms;
    • setInterval:如果时间间隔小于10ms,则将时间间隔增加到10ms。
  • 在最新标准中:如果时间间隔小于0,则将时间间隔设置为0。 如果嵌套级别大于5,并且时间间隔小于4ms,则将时间间隔设置为4ms。

定时器的清除

  • 由于clearTimeout()和clearInterval()清除的是同一列表(活动计时器列表)中的条目,因此可以使用这两种方法清除setTimeout()或 setInterval()创建的计时器。

参考资料

往期高分合集:

本文首发于个人博客,欢迎指正和star

查看原文

赞 15 收藏 12 评论 2

独钓寒江雪 赞了文章 · 1月5日

4图看懂React SSR中的hydrate

React CSR:水车模型

当初在理解 React CSR 时做过一个比喻,把单向数据流比作瀑布模型

瀑布模型:由props(水管)和state(水源)把组件组织起来,组件间数据流向类似于瀑布。数据流向总是从祖先到子孙(从根到叶子),不会逆流

(摘自深入 React

单组件的微观视角下,我们把props理解为水管(数据通道),接收外部传递进来的数据(水),每一份state都是一处水源(想象泉眼冒水,即产生数据的地方),将这棵通过props管道连接而成的组件树立起来,就形成了自上而下的水流(瀑布):

想象上图整面瀑布墙上有无数的泉眼,state值顺着props管道流淌

从更宏大的视角来看,组件树就像是一系列竹管连接起来的水车,数据是水源(statepropscontext以及外部数据源),水自上而下地流经整个组件树到达叶子组件,渲染出漂亮的视图

先通过一张图来感受竹管输水:

再感受水源以及水车整体的运转:

左侧的小桶就是外部数据源,随时舀起一瓢灌到某个组件(竹管)中,让其内部的state(储水)发生变化,变化的水流经过整个子树到达叶子组件,渲染出变化后的视图,这就是交互操作导致数据变化时的组件更新过程

React SSR:三体人模型

CSR 模式下,我们把水理解为数据,同样适用于 SSR,只是过程稍复杂些:

  1. 服务端渲染:在服务端注入数据,构建出组件树
  2. 序列化成 HTML:脱水成人干
  3. 客户端渲染:到达客户端后泡水,激活水流,变回活人

类比三体人的生存模式,乱纪元来临时先脱水成人干(SSR 中的服务端渲染部分),恒纪元到来后再泡水复活(SSR 中的客户端 hydrate 部分)

喝水(render)

首先要有水可脱,所以先要拉取数据(水),在服务端完成组件首次渲染(mount)的过程:

也就是根据外部数据构建出初始组件树,过程中仅执行render及之前的几个生命周期,是为了尽可能缩短保命招数的前摇,尽快脱水

脱水(dehydrate)

接着对组件树进行脱水,使其在恶劣的环境同样能够以一种更简单的形态“生存”下来,比如禁用了 JavaScript 的客户端环境

比组件树更简单的形态是 HTML 片段,脱去生命的水气(动态数据),成为风干标本一样的静态快照:

内存里的组件树被序列化成了静态的 HTML 片段,还能看出来人样(初始视图),不过已经无法与之交互了,但这种便携的形态尤其适合运输,能够通过网络传输到地球上的某个客户端

注水(hydrate)

抵达客户端后,如果环境适宜(没有禁用 JavaScript),就立即开始“浸泡”(hydrate),组件随之复苏

客户端“浸泡”的过程实际上是重新创建了组件树,将新生的水(statepropscontext等)注入其中,并将鲜活的组件树塞进服务端渲染的干瘪躯壳里,使之复活

注水复活其实比三体人浸泡复苏更强大一些,能够修复肢体性的损伤(缺失的 HTML 结构会重新创建),但并不纠正口歪眼斜之类的小毛病(忽略属性多了少了、属性值对不上之类的问题,具体见React SSR 之原理篇

P.S.浸泡也需要一定时间,所以在 SSR 模式下,客户端有一段时间是无法正常交互的,注水完成之后才能彻底复活(单向数据流和交互行为都恢复正常)

参考资料

有所得、有所惑,真好

关注「前端向后」微信公众号,你将收获一系列「用原创」的高质量技术文章,主题包括但不限于前端、Node.js以及服务端技术

本文首发于 ayqy.net ,原文链接:http://www.ayqy.net/blog/ssr-...

查看原文

赞 1 收藏 0 评论 0

独钓寒江雪 赞了文章 · 1月1日

前端开发者必备的Nginx知识

nginx在应用程序中的作用

  • 解决跨域
  • 请求过滤
  • 配置gzip
  • 负载均衡
  • 静态资源服务器
nginx是一个高性能的HTTP和反向代理服务器,也是一个通用的TCP/UDP代理服务器,最初由俄罗斯人Igor Sysoev编写。

nginx现在几乎是众多大型网站的必用技术,大多数情况下,我们不需要亲自去配置它,但是了解它在应用程序中所担任的角色,以及如何解决这些问题是非常必要的。

下面我将从nginx在企业中的真实应用来解释nginx在应用程序中起到的作用。

为了便于理解,首先先来了解一下一些基础知识,nginx是一个高性能的反向代理服务器那么什么是反向代理呢?

正向代理与反向代理

代理是在服务器和客户端之间假设的一层服务器,代理将接收客户端的请求并将它转发给服务器,然后将服务端的响应转发给客户端。

不管是正向代理还是反向代理,实现的都是上面的功能。

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/nginx2.png)

正向代理

正向代理,意思是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。

正向代理是为我们服务的,即为客户端服务的,客户端可以根据正向代理访问到它本身无法访问到的服务器资源。

正向代理对我们是透明的,对服务端是非透明的,即服务端并不知道自己收到的是来自代理的访问还是来自真实客户端的访问。

反向代理

反向代理(Reverse Proxy)方式是指以代理服务器来接受internet上的连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给internet上请求连接的客户端,此时代理服务器对外就表现为一个反向代理服务器。

反向代理是为服务端服务的,反向代理可以帮助服务器接收来自客户端的请求,帮助服务器做请求转发,负载均衡等。

反向代理对服务端是透明的,对我们是非透明的,即我们并不知道自己访问的是代理服务器,而服务器知道反向代理在为他服务。
图片描述

基本配置

配置结构

下面是一个nginx配置文件的基本结构:

events { 

}

http 
{
    server
    { 
        location path
        {
            ...
        }
        location path
        {
            ...
        }
     }

    server
    {
        ...
    }

}
  • main:nginx的全局配置,对全局生效。
  • events:配置影响nginx服务器或与用户的网络连接。
  • http:可以嵌套多个server,配置代理,缓存,日志定义等绝大多数功能和第三方模块的配置。
  • server:配置虚拟主机的相关参数,一个http中可以有多个server。
  • location:配置请求的路由,以及各种页面的处理情况。
  • upstream:配置后端服务器具体地址,负载均衡配置不可或缺的部分。

内置变量

下面是nginx一些配置中常用的内置全局变量,你可以在配置的任何位置使用它们。

| 变量名 | 功能 |
| ------ | ------ |
| $host| 请求信息中的Host,如果请求中没有Host行,则等于设置的服务器名 |
| $request_method | 客户端请求类型,如GETPOST
| $remote_addr | 客户端的IP地址 |
|$args | 请求中的参数 |
|$content_length| 请求头中的Content-length字段 |
|$http_user_agent | 客户端agent信息 |
|$http_cookie | 客户端cookie信息 |
|$remote_addr | 客户端的IP地址 |
|$remote_port | 客户端的端口 |
|$server_protocol | 请求使用的协议,如HTTP/1.0、·HTTP/1.1` |
|$server_addr | 服务器地址 |
|$server_name| 服务器名称|
|$server_port|服务器的端口号|

解决跨域

先追本溯源以下,跨域究竟是怎么回事。

跨域的定义

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。通常不允许不同源间的读操作。

同源的定义

如果两个页面的协议,端口(如果有指定)和域名都相同,则两个页面具有相同的源。

image

nginx解决跨域的原理

例如:

  • 前端server的域名为:fe.server.com
  • 后端服务的域名为:dev.server.com

现在我在fe.server.comdev.server.com发起请求一定会出现跨域。

现在我们只需要启动一个nginx服务器,将server_name设置为fe.server.com,然后设置相应的location以拦截前端需要跨域的请求,最后将请求代理回dev.server.com。如下面的配置:

server {
        listen       80;
        server_name  fe.server.com;
        location / {
                proxy_pass dev.server.com;
        }
}

这样可以完美绕过浏览器的同源策略:fe.server.com访问nginxfe.server.com属于同源访问,而nginx对服务端转发的请求不会触发浏览器的同源策略。

请求过滤

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/404.jpg)

根据状态码过滤

error_page 500 501 502 503 504 506 /50x.html;
    location = /50x.html {
        #将跟路径改编为存放html的路径。
        root /root/static/html;
    }

根据URL名称过滤,精准匹配URL,不匹配的URL全部重定向到主页。

location / {
    rewrite  ^.*$ /index.html  redirect;
}

根据请求类型过滤。

if ( $request_method !~ ^(GET|POST|HEAD)$ ) {
        return 403;
    }

配置gzip

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/gzip.jpg)

GZIP是规定的三种标准HTTP压缩格式之一。目前绝大多数的网站都在使用 GZIP 传输 HTMLCSSJavaScript 等资源文件。

对于文本文件,GZip 的效果非常明显,开启后传输所需流量大约会降至 1/4 ~ 1/3

并不是每个浏览器都支持gzip的,如何知道客户端是否支持gzip呢,请求头中的Accept-Encoding来标识对压缩的支持。

image

启用gzip同时需要客户端和服务端的支持,如果客户端支持gzip的解析,那么只要服务端能够返回gzip的文件就可以启用gzip了,我们可以通过nginx的配置来让服务端支持gzip。下面的responecontent-encoding:gzip,指服务端开启了gzip的压缩方式。

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/gzip2.png)

    gzip                    on;
    gzip_http_version       1.1;        
    gzip_comp_level         5;
    gzip_min_length         1000;
    gzip_types text/csv text/xml text/css text/plain text/javascript application/javascript application/x-javascript application/json application/xml;

gzip

  • 开启或者关闭gzip模块
  • 默认值为 off
  • 可配置为 on / off

gzip_http_version

  • 启用 GZip 所需的 HTTP 最低版本
  • 默认值为 HTTP/1.1

这里为什么默认版本不是1.0呢?

HTTP 运行在 TCP 连接之上,自然也有着跟 TCP 一样的三次握手、慢启动等特性。

启用持久连接情况下,服务器发出响应后让TCP连接继续打开着。同一对客户/服务器之间的后续请求和响应可以通过这个连接发送。

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/keepalive.png)

为了尽可能的提高 HTTP 性能,使用持久连接就显得尤为重要了。

HTTP/1.1 默认支持 TCP 持久连接,HTTP/1.0 也可以通过显式指定 Connection: keep-alive 来启用持久连接。对于 TCP 持久连接上的 HTTP 报文,客户端需要一种机制来准确判断结束位置,而在 HTTP/1.0 中,这种机制只有 Content-Length。而在HTTP/1.1 中新增的 Transfer-Encoding: chunked 所对应的分块传输机制可以完美解决这类问题。

nginx同样有着配置chunked的属性chunked_transfer_encoding,这个属性是默认开启的。

Nginx 在启用了GZip的情况下,不会等文件 GZip 完成再返回响应,而是边压缩边响应,这样可以显著提高 TTFB(Time To First Byte,首字节时间,WEB 性能优化重要指标)。这样唯一的问题是,Nginx 开始返回响应时,它无法知道将要传输的文件最终有多大,也就是无法给出 Content-Length 这个响应头部。

所以,在HTTP1.0中如果利用Nginx 启用了GZip,是无法获得 Content-Length 的,这导致HTTP1.0中开启持久链接和使用GZip只能二选一,所以在这里gzip_http_version默认设置为1.1

gzip_comp_level

  • 压缩级别,级别越高压缩率越大,当然压缩时间也就越长(传输快但比较消耗cpu)。
  • 默认值为 1
  • 压缩级别取值为1-9

gzip_min_length

  • 设置允许压缩的页面最小字节数,Content-Length小于该值的请求将不会被压缩
  • 默认值:0
  • 当设置的值较小时,压缩后的长度可能比原文件大,建议设置1000以上

gzip_types

  • 要采用gzip压缩的文件类型(MIME类型)
  • 默认值:text/html(默认不压缩js/css)

负载均衡

什么是负载均衡

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/nginx3.jpg)

如上面的图,前面是众多的服务窗口,下面有很多用户需要服务,我们需要一个工具或策略来帮助我们将如此多的用户分配到每个窗口,来达到资源的充分利用以及更少的排队时间。

把前面的服务窗口想像成我们的后端服务器,而后面终端的人则是无数个客户端正在发起请求。负载均衡就是用来帮助我们将众多的客户端请求合理的分配到各个服务器,以达到服务端资源的充分利用和更少的请求时间。

nginx如何实现负载均衡

Upstream指定后端服务器地址列表

upstream balanceServer {
    server 10.1.22.33:12345;
    server 10.1.22.34:12345;
    server 10.1.22.35:12345;
}

在server中拦截响应请求,并将请求转发到Upstream中配置的服务器列表。

    server {
        server_name  fe.server.com;
        listen 80;
        location /api {
            proxy_pass http://balanceServer;
        }
    }

上面的配置只是指定了nginx需要转发的服务端列表,并没有指定分配策略。

nginx实现负载均衡的策略

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/loadBalancing.png)

轮询策略

默认情况下采用的策略,将所有客户端请求轮询分配给服务端。这种策略是可以正常工作的,但是如果其中某一台服务器压力太大,出现延迟,会影响所有分配在这台服务器下的用户。

upstream balanceServer {
    server 10.1.22.33:12345;
    server 10.1.22.34:12345;
    server 10.1.22.35:12345;
}

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/nginx5.png)

最小连接数策略

将请求优先分配给压力较小的服务器,它可以平衡每个队列的长度,并避免向压力大的服务器添加更多的请求。

upstream balanceServer {
    least_conn;
    server 10.1.22.33:12345;
    server 10.1.22.34:12345;
    server 10.1.22.35:12345;
}

![image](https://lsqimg-1257917459.cos-website.ap-beijing.myqcloud.com/blog/nginx4.png)

最快响应时间策略

依赖于NGINX Plus,优先分配给响应时间最短的服务器。

upstream balanceServer {
    fair;
    server 10.1.22.33:12345;
    server 10.1.22.34:12345;
    server 10.1.22.35:12345;
}

客户端ip绑定

来自同一个ip的请求永远只分配一台服务器,有效解决了动态网页存在的session共享问题。

upstream balanceServer {
    ip_hash;
    server 10.1.22.33:12345;
    server 10.1.22.34:12345;
    server 10.1.22.35:12345;
}

静态资源服务器

location ~* \.(png|gif|jpg|jpeg)$ {
    root    /root/static/;  
    autoindex on;
    access_log  off;
    expires     10h;# 设置过期时间为10小时          
}

匹配以png|gif|jpg|jpeg为结尾的请求,并将请求转发到本地路径,root中指定的路径即nginx本地路径。同时也可以进行一些缓存的设置。

小结

nginx的功能非常强大,还有很多需要探索,上面的一些配置都是公司配置的真实应用(精简过了)。

文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注。

想阅读更多优质文章、可关注我的github博客,你的star✨、点赞和关注是我持续创作的动力!

推荐大家使用Fundebug,一款很好用的BUG监控工具~

推荐关注我的微信公众号【code秘密花园】,每天推送高质量文章,我们一起交流成长。

图片描述

查看原文

赞 370 收藏 291 评论 14

独钓寒江雪 赞了文章 · 2020-12-22

20 个值得研究的 Vue 开源项目

译者:前端小智
作者:Nastassia Ovchinnikova
来源:flatlogic.com
点赞再看,微信搜索大迁世界,B站关注前端小智这个没有大厂背景,但有着一股向上积极心态人。本文 GitHubhttps://github.com/qq44924588... 上已经收录,文章的已分类,也整理了很多我的文档,和教程资料。

最近开源了一个 Vue 组件,还不够完善,欢迎大家来一起完善它,也希望大家能给个 star 支持一下,谢谢各位了。

github 地址:https://github.com/qq44924588...

Vue 相对不于 React 的一个优点是它易于理解和学习,且在国内占大多数。咱们可以在 Vue 的帮助下创建任何 Web 应用程序。 因此,时时了解一些新出现又好用的Vue 开源项目也是挺重要,一方面可以帮助咱们更加高效的开发,另一方面,咱们也可以模范学习其精华部分。

接下来看看新出的有哪些好用的开源项目。

uiGradients

网址:http://uigradients.com/

GitHub:https://github.com/ghosh/uiGr...

GitHub Stars:4.6k

clipboard.png

彩色阵列和出色的UX使是这个项目的一个亮点,渐变仍然是网页设计中日益增长的趋势。 咱们可以选择所需的颜色,并可以获得所有可能的渐变,并获取对应的 CSS 代码, 赶紧收藏起来吧。

CSSFX

CSS 过度效果的集合

网址:https://cssfx.dev

GitHub:https://github.com/jolaleye/c...

GitHub Stars:3.5k

图片描述

CSSFX 里面有很多 CSS 过滤效果,咱们可以根据需求选择特定的动画,点击对应的效果即可看到生成的 CSS 代码,动手搞起来吧。

Sing App Vue Dashboard

一个管理模板

网址:https://flatlogic.com/templat...

GitHub:https://github.com/flatlogic/...

GitHub Stars:254

事例:https://flatlogic.com/templat...

文档:https://demo.flatlogic.com/si...

clipboard.png

这是基于最新 Vue 和 Bootstrap 免费和开源的管理模板,其实跟咱们国内的 vue-admin-template 差不多。咱们不一定要使用它,但可以研究学习源码,相信可以学到很多实用的技巧,加油少年。

Vue Storefront

网址:https://www.vuestorefront.io

GitHub:https://github.com/DivanteLtd...

GitHub Stars:5.8k

clipboard.png

这是一个PWA,可以连接到任何后端(或几乎任何后端)。这个项目的主要优点是使用了无头架构。这是一种全面的解决方案,为咱们提供了许多可能性(巨大的支持稳步增长的社区,服务器端渲染,将改善网页SEO,移动优先的方法和离线模式。

Faviator

图标生成的库

网址:https://www.faviator.xyz

GitHub:https://www.faviator.xyz/play...

GitHub Stars:94

clipboard.png

如果需要创建一个图标增加体验度。 可以使用任何 Google 字体以及任何颜色。只需通过首选的配置,然后选择PNG,SVG或JPG格式即可。

iView

Vue UI 组件库

网址:https://iviewui.com/

GitHub:https://github.com/iview/iview

GitHub Stars:22.8k

clipboard.png

不断迭代更新使这组UI组件成为具有任何技能水平的开发人员的不错选择。

要使用iView,需要对单一文件组件有充分的了解,该项目具有友好的API和大量文档。

Postwoman

API请求构建器

网址:https://postwoman.io/

GitHub:https://github.com/liyasthoma...

GitHub Stars:10.5k

clipboard.png

这个与 Postman 类似。 它是免费的,具有许多参与者,并且具有多平台和多设备支持。 这个工具真的非常快,并且有大量的更新。 该工具的创建者声称在不久的将来会有更多功能。

Vue Virtual Scroller

快速滚动

网址:https://akryum.github.io/vue-...

GitHub:https://github.com/Akryum/vue...

GitHub Stars:3.4k

clipboard.png

Vue Virtual Scroller具有四个主要组件。 RecycleScroller可以渲染列表中的可见项。 如果咱们不知道数据具体的数量,最好使用DynamicScrollerDynamicScrollerItem将所有内容包装在DynamicScroller中(以处理大小更改)。 IdState简化了本地状态管理(在RecycleScroller内部)。

Mint UI

移动端的 UI 库

网址:http://mint-ui.github.io/#!/en

GitHub:https://github.com/ElemeFE/mi...

GitHub Stars:15.2k

clipboard.png

使用现成的CSS和JS组件更快地构建移动应用程序。使用此工具,咱们不必承担文件大小过大的风险,因为可以按需加载。动画由CSS3处理,由此来提高性能。

大家都说简历没项目写,我就帮大家找了一个项目,还附赠【搭建教程】

V Calendar

用于构建日历的无依赖插件

网址:https://vcalendar.io

GitHub:https://github.com/nathanreye...

GitHub Stars:1.6k

clipboard.png

您可以选择不同的视觉指示器来装饰日历。 V Calendar还为咱们提供了三种日期选择模式:

  • 单选
  • 多选
  • 日期范围

Vue Design System

一组UI工具

网址:https://vueds.com/

GitHub:https://github.com/viljamis/v...

GitHub Stars:1.7k

clipboard.png

这是一种组织良好的工具,对于任何web开发团队来说,它的命名都很容易理解。其中一个很大的优点是使用了更漂亮的代码格式化器,它可以在提交到Git之前自动排列代码。

Proppy

UI组件的功能道具组合

网址:https://proppyjs.com

GitHub:https://github.com/fahad19/pr...

GitHub Stars:856

clipboard.png

ProppyJS 是一个很小的库,用于组合道具,它附带了各种集成包,让您可以自由地使用它流行的渲染库。

我们的想法是首先将Component的行为表达为props,然后使用Proppy的相同API将其连接到您的Component(可以是React,Vue.js或Preact)。

API还允许您访问其他应用程序范围的依赖项(如使用Redux的商店),以方便组件树中的任何位置。

Light Blue Vue Admin

vue 后台展示模板

网址:https://flatlogic.com/templat...

GitHub:https://github.com/flatlogic/...

GitHub Stars:79

图片描述

事例:https://demo.flatlogic.com/li...

文档:https://demo.flatlogic.com/li...

模板是用Vue CLIBootstrap 4构建的。从演示中可以看到,这个模板有一组非常基本的页面:排版、地图、图表、聊天界面等。如果咱们需要一个扩展的模板,可以看看Light Blue Vue Full,它有60多个组件,无 jquery,有两个颜色主题。

Vue API Query

为 REST API 构建请求

GitHub:https://github.com/robsonteno...
GitHub Stars: 1.1k

clipboard.png

关于这个项目没什么好说的。它所做的与描述行中所写的完全一样:它帮助咱们构建REST API的请求。

Vue Grid Layout

Vue 的网格布局

Website:https://jbaysolutions.github....
GitHub:https://github.com/jbaysoluti...
GitHub Stars: 3.1k

clipboard.png

所有网格相关问题的简单解决方案。它有静态的、可调整大小的和可拖动的小部件。还是响应和布局可以恢复和序列化。如果还需要再添加一个小部件,则不必重新构建所有网格。

Vue Content Loader

创建一个占位符加载

Website:http://danilowoz.com/create-v...
GitHub:https://github.com/egoist/vue...
GitHub Stars: 2k

clipboard.png

当咱们开发网站或者 APP 时,遇到内容过多加载速度慢时,会导致用户打开页面有大量空白页,vue-content-loader正是解决这个问题的一个组件,使加载内容之前生成一个dom模板,提高用户体验。

Echarts with Vue2.0

数据可视化

Website:https://simonzhangiter.github...
GitHub:https://github.com/SimonZhang...
GitHub Stars: 1.3k

clipboard.png

在图片中,咱们可以看到非常漂亮的图表。这个项目使任何数据都更具可读性,更容易理解和解释。它允许咱们在任何数据集中轻松地检测趋势和模式。

大家都说简历没项目写,我就帮大家找了一个项目,还附赠【搭建教程】

Vue.js Modal

高度可定制的模态框

Website:http://vue-js-modal.yev.io/
GitHub:https://github.com/euvl/vue-j...
GitHub Stars: 2.9k

clipboard.png

可以在该网站上查看所有不同类型的模态。 有15个按钮,按任意一个按钮,看到一个模态示例。

Vuesax

框架组件

Website:https://lusaxweb.github.io/vu...
GitHub:https://github.com/lusaxweb/v...
GitHub Stars: 3.7k

clipboard.png

这个项目在社区中很受欢迎。 它使咱们可以为每个组件设计不同的风格。 Vuesax的创建者强调,每个Web开发人员在进行Web设计时都应有选择的自由。

Vue2 Animate

vue2.0 —使用animate.css 构建项目和创建组件

Website:https://the-allstars.com/vue2...
GitHub:https://github.com/asika32764...
GitHub Stars: 1.1k

clipboard.png

这个库是跨浏览器的,咱们可以选择从5种类型的动画: rotateslidefadebouncezoom。在网站上有一个演示。动画的默认持续时间是1秒,但是咱们可以自定义该参数。

Vuetensils

Vue.js的工具集

Website:https://vuetensils.stegosourc...
GitHub:https://github.com/stegosourc...
GitHub Stars: 111

clipboard.png

这个UI库有一个标准的功能,但是最酷的是它没有额外的样式。你可以让设计尽可能的个性化,应用所有的需求。只需编写需要的样式,将其添加到项目中,并包含需要的尽可能多的组件。

人才们的 【三连】 就是小智不断分享的最大动力,如果本篇博客有任何错误和建议,欢迎人才们留言,最后,谢谢大家的观看。


编辑中可能存在的bug没法实时知道,事后为了解决这些bug,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

原文:

https://flatlogic.com/blog/ne...
https://flatlogic.com/blog/ne...


交流

文章每周持续更新,可以微信搜索「 大迁世界 」第一时间阅读和催更(比博客早一到两篇哟),本文 GitHub https://github.com/qq449245884/xiaozhi 已经收录,整理了很多我的文档,欢迎Star和完善,大家面试可以参照考点复习,另外关注公众号,后台回复福利,即可看到福利,你懂的。

查看原文

赞 35 收藏 27 评论 1

独钓寒江雪 赞了文章 · 2020-12-21

可视化拖拽组件库一些技术要点原理分析

本文主要对以下技术要点进行分析:

  1. 编辑器
  2. 自定义组件
  3. 拖拽
  4. 删除组件、调整图层层级
  5. 放大缩小
  6. 撤消、重做
  7. 组件属性设置
  8. 吸附
  9. 预览、保存代码
  10. 绑定事件
  11. 绑定动画
  12. 导入 PSD
  13. 手机模式

为了让本文更加容易理解,我将以上技术要点结合在一起写了一个可视化拖拽组件库 DEMO:

建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。

1. 编辑器

先来看一下页面的整体结构。

这一节要讲的编辑器其实就是中间的画布。它的作用是:当从左边组件列表拖拽出一个组件放到画布中时,画布要把这个组件渲染出来。

这个编辑器的实现思路是:

  1. 用一个数组 componentData 维护编辑器中的数据。
  2. 把组件拖拽到画布中时,使用 push() 方法将新的组件数据添加到 componentData
  3. 编辑器使用 v-for 指令遍历 componentData,将每个组件逐个渲染到画布(也可以使用 JSX 语法结合 render() 方法代替)。

编辑器渲染的核心代码如下所示:

<component 
  v-for="item in componentData"
  :key="item.id"
  :is="item.component"
  :style="item.style"
  :propValue="item.propValue"
/>

每个组件数据大概是这样:

{
    component: 'v-text', // 组件名称,需要提前注册到 Vue
    label: '文字', // 左侧组件列表中显示的名字
    propValue: '文字', // 组件所使用的值
    icon: 'el-icon-edit', // 左侧组件列表中显示的名字
    animations: [], // 动画列表
    events: {}, // 事件列表
    style: { // 组件样式
        width: 200,
        height: 33,
        fontSize: 14,
        fontWeight: 500,
        lineHeight: '',
        letterSpacing: 0,
        textAlign: '',
        color: '',
    },
}

在遍历 componentData 组件数据时,主要靠 is 属性来识别出真正要渲染的是哪个组件。

例如要渲染的组件数据是 { component: 'v-text' },则 <component :is="item.component" /> 会被转换为 <v-text />。当然,你这个组件也要提前注册到 Vue 中。

如果你想了解更多 is 属性的资料,请查看官方文档

2. 自定义组件

原则上使用第三方组件也是可以的,但建议你最好封装一下。不管是第三方组件还是自定义组件,每个组件所需的属性可能都不一样,所以每个组件数据可以暴露出一个属性 propValue 用于传递值。

例如 a 组件只需要一个属性,你的 propValue 可以这样写:propValue: 'aaa'。如果需要多个属性,propValue 则可以是一个对象:

propValue: {
  a: 1,
  b: 'text'
}

在这个 DEMO 组件库中我定义了三个组件。

图片组件 Picture

<template>
    <div style="overflow: hidden">
        <img :data-original="propValue">
    </div>
</template>

<script>
export default {
    props: {
        propValue: {
            type: String,
            require: true,
        },
    },
}
</script>

按钮组件 VButton:

<template>
    <button class="v-button">{{ propValue }}</button>
</template>

<script>
export default {
    props: {
        propValue: {
            type: String,
            default: '',
        },
    },
}
</script>

文本组件 VText:

<template>
    <textarea 
        v-if="editMode == 'edit'"
        :value="propValue"
        class="text textarea"
        @input="handleInput"
        ref="v-text"
    ></textarea>
    <div v-else class="text disabled">
        <div v-for="(text, index) in propValue.split('\n')" :key="index">{{ text }}</div>
    </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
    },
    computed: mapState([
        'editMode',
    ]),
    methods: {
        handleInput(e) {
            this.$emit('input', this.element, e.target.value)
        },
    },
}
</script>

3. 拖拽

从组件列表到画布

一个元素如果要设为可拖拽,必须给它添加一个 draggable 属性。另外,在将组件列表中的组件拖拽到画布中,还有两个事件是起到关键作用的:

  1. dragstart 事件,在拖拽刚开始时触发。它主要用于将拖拽的组件信息传递给画布。
  2. drop 事件,在拖拽结束时触发。主要用于接收拖拽的组件信息。

先来看一下左侧组件列表的代码:

<div @dragstart="handleDragStart" class="component-list">
    <div v-for="(item, index) in componentList" :key="index" class="list" draggable :data-index="index">
        <i :class="item.icon"></i>
        <span>{{ item.label }}</span>
    </div>
</div>
handleDragStart(e) {
    e.dataTransfer.setData('index', e.target.dataset.index)
}

可以看到给列表中的每一个组件都设置了 draggable 属性。另外,在触发 dragstart 事件时,使用 dataTransfer.setData() 传输数据。再来看一下接收数据的代码:

<div class="content" @drop="handleDrop" @dragover="handleDragOver" @click="deselectCurComponent">
    <Editor />
</div>
handleDrop(e) {
    e.preventDefault()
    e.stopPropagation()
    const component = deepCopy(componentList[e.dataTransfer.getData('index')])
    this.$store.commit('addComponent', component)
}

触发 drop 事件时,使用 dataTransfer.getData() 接收传输过来的索引数据,然后根据索引找到对应的组件数据,再添加到画布,从而渲染组件。

组件在画布中移动

首先需要将画布设为相对定位 position: relative,然后将每个组件设为绝对定位 position: absolute。除了这一点外,还要通过监听三个事件来进行移动:

  1. mousedown 事件,在组件上按下鼠标时,记录组件当前的位置,即 xy 坐标(为了方便讲解,这里使用的坐标轴,实际上 xy 对应的是 css 中的 lefttop
  2. mousemove 事件,每次鼠标移动时,都用当前最新的 xy 坐标减去最开始的 xy 坐标,从而计算出移动距离,再改变组件位置。
  3. mouseup 事件,鼠标抬起时结束移动。
handleMouseDown(e) {
    e.stopPropagation()
    this.$store.commit('setCurComponent', { component: this.element, zIndex: this.zIndex })

    const pos = { ...this.defaultStyle }
    const startY = e.clientY
    const startX = e.clientX
    // 如果直接修改属性,值的类型会变为字符串,所以要转为数值型
    const startTop = Number(pos.top)
    const startLeft = Number(pos.left)

    const move = (moveEvent) => {
        const currX = moveEvent.clientX
        const currY = moveEvent.clientY
        pos.top = currY - startY + startTop
        pos.left = currX - startX + startLeft
        // 修改当前组件样式
        this.$store.commit('setShapeStyle', pos)
    }

    const up = () => {
        document.removeEventListener('mousemove', move)
        document.removeEventListener('mouseup', up)
    }

    document.addEventListener('mousemove', move)
    document.addEventListener('mouseup', up)
}

4. 删除组件、调整图层层级

改变图层层级

由于拖拽组件到画布中是有先后顺序的,所以可以按照数据顺序来分配图层层级。

例如画布新增了五个组件 abcde,那它们在画布数据中的顺序为 [a, b, c, d, e],图层层级和索引一一对应,即它们的 z-index 属性值是 01234(后来居上)。用代码表示如下:

<div v-for="(item, index) in componentData" :zIndex="index"></div>

如果不了解 z-index 属性的,请看一下 MDN 文档

理解了这一点之后,改变图层层级就很容易做到了。改变图层层级,即是改变组件数据在 componentData 数组中的顺序。例如有 [a, b, c] 三个组件,它们的图层层级从低到高顺序为 abc(索引越大,层级越高)。

如果要将 b 组件上移,只需将它和 c 调换顺序即可:

const temp = componentData[1]
componentData[1] = componentData[2]
componentData[2] = temp

同理,置顶置底也是一样,例如我要将 a 组件置顶,只需将 a 和最后一个组件调换顺序即可:

const temp = componentData[0]
componentData[0] = componentData[componentData.lenght - 1]
componentData[componentData.lenght - 1] = temp

删除组件

删除组件非常简单,一行代码搞定:componentData.splice(index, 1)

5. 放大缩小

细心的网友可能会发现,点击画布上的组件时,组件上会出现 8 个小圆点。这 8 个小圆点就是用来放大缩小用的。实现原理如下:

1. 在每个组件外面包一层 Shape 组件,Shape 组件里包含 8 个小圆点和一个 <slot> 插槽,用于放置组件。

<!--页面组件列表展示-->
<Shape v-for="(item, index) in componentData"
    :defaultStyle="item.style"
    :style="getShapeStyle(item.style, index)"
    :key="item.id"
    :active="item === curComponent"
    :element="item"
    :zIndex="index"
>
    <component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    />
</Shape>

Shape 组件内部结构:

<template>
    <div class="shape" :class="{ active: this.active }" @click="selectCurComponent" @mousedown="handleMouseDown"
    @contextmenu="handleContextMenu">
        <div
            class="shape-point"
            v-for="(item, index) in (active? pointList : [])"
            @mousedown="handleMouseDownOnPoint(item)"
            :key="index"
            :style="getPointStyle(item)">
        </div>
        <slot></slot>
    </div>
</template>

2. 点击组件时,将 8 个小圆点显示出来。

起作用的是这行代码 :active="item === curComponent"

3. 计算每个小圆点的位置。

先来看一下计算小圆点位置的代码:

const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb']

getPointStyle(point) {
    const { width, height } = this.defaultStyle
    const hasT = /t/.test(point)
    const hasB = /b/.test(point)
    const hasL = /l/.test(point)
    const hasR = /r/.test(point)
    let newLeft = 0
    let newTop = 0

    // 四个角的点
    if (point.length === 2) {
        newLeft = hasL? 0 : width
        newTop = hasT? 0 : height
    } else {
        // 上下两点的点,宽度居中
        if (hasT || hasB) {
            newLeft = width / 2
            newTop = hasT? 0 : height
        }

        // 左右两边的点,高度居中
        if (hasL || hasR) {
            newLeft = hasL? 0 : width
            newTop = Math.floor(height / 2)
        }
    }

    const style = {
        marginLeft: hasR? '-4px' : '-3px',
        marginTop: '-3px',
        left: `${newLeft}px`,
        top: `${newTop}px`,
        cursor: point.split('').reverse().map(m => this.directionKey[m]).join('') + '-resize',
    }

    return style
}

计算小圆点的位置需要获取一些信息:

  • 组件的高度 height、宽度 width

注意,小圆点也是绝对定位的,相对于 Shape 组件。所以有四个小圆点的位置很好确定:

  1. 左上角的小圆点,坐标 left: 0, top: 0
  2. 右上角的小圆点,坐标 left: width, top: 0
  3. 左下角的小圆点,坐标 left: 0, top: height
  4. 右下角的小圆点,坐标 left: width, top: height

另外的四个小圆点需要通过计算间接算出来。例如左边中间的小圆点,计算公式为 left: 0, top: height / 2,其他小圆点同理。

4. 点击小圆点时,可以进行放大缩小操作。

handleMouseDownOnPoint(point) {
    const downEvent = window.event
    downEvent.stopPropagation()
    downEvent.preventDefault()

    const pos = { ...this.defaultStyle }
    const height = Number(pos.height)
    const width = Number(pos.width)
    const top = Number(pos.top)
    const left = Number(pos.left)
    const startX = downEvent.clientX
    const startY = downEvent.clientY

    // 是否需要保存快照
    let needSave = false
    const move = (moveEvent) => {
        needSave = true
        const currX = moveEvent.clientX
        const currY = moveEvent.clientY
        const disY = currY - startY
        const disX = currX - startX
        const hasT = /t/.test(point)
        const hasB = /b/.test(point)
        const hasL = /l/.test(point)
        const hasR = /r/.test(point)
        const newHeight = height + (hasT? -disY : hasB? disY : 0)
        const newWidth = width + (hasL? -disX : hasR? disX : 0)
        pos.height = newHeight > 0? newHeight : 0
        pos.width = newWidth > 0? newWidth : 0
        pos.left = left + (hasL? disX : 0)
        pos.top = top + (hasT? disY : 0)
        this.$store.commit('setShapeStyle', pos)
    }

    const up = () => {
        document.removeEventListener('mousemove', move)
        document.removeEventListener('mouseup', up)
        needSave && this.$store.commit('recordSnapshot')
    }

    document.addEventListener('mousemove', move)
    document.addEventListener('mouseup', up)
}

它的原理是这样的:

  1. 点击小圆点时,记录点击的坐标 xy。
  2. 假设我们现在向下拖动,那么 y 坐标就会增大。
  3. 用新的 y 坐标减去原来的 y 坐标,就可以知道在纵轴方向的移动距离是多少。
  4. 最后再将移动距离加上原来组件的高度,就可以得出新的组件高度。
  5. 如果是正数,说明是往下拉,组件的高度在增加。如果是负数,说明是往上拉,组件的高度在减少。

6. 撤消、重做

撤销重做的实现原理其实挺简单的,先看一下代码:

snapshotData: [], // 编辑器快照数据
snapshotIndex: -1, // 快照索引
        
undo(state) {
    if (state.snapshotIndex >= 0) {
        state.snapshotIndex--
        store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
    }
},

redo(state) {
    if (state.snapshotIndex < state.snapshotData.length - 1) {
        state.snapshotIndex++
        store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
    }
},

setComponentData(state, componentData = []) {
    Vue.set(state, 'componentData', componentData)
},

recordSnapshot(state) {
    // 添加新的快照
    state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData)
    // 在 undo 过程中,添加新的快照时,要将它后面的快照清理掉
    if (state.snapshotIndex < state.snapshotData.length - 1) {
        state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
    }
},

用一个数组来保存编辑器的快照数据。保存快照就是不停地执行 push() 操作,将当前的编辑器数据推入 snapshotData 数组,并增加快照索引 snapshotIndex。目前以下几个动作会触发保存快照操作:

  • 新增组件
  • 删除组件
  • 改变图层层级
  • 拖动组件结束时

...

撤销

假设现在 snapshotData 保存了 4 个快照。即 [a, b, c, d],对应的快照索引为 3。如果这时进行了撤销操作,我们需要将快照索引减 1,然后将对应的快照数据赋值给画布。

例如当前画布数据是 d,进行撤销后,索引 -1,现在画布的数据是 c。

重做

明白了撤销,那重做就很好理解了,就是将快照索引加 1,然后将对应的快照数据赋值给画布。

不过还有一点要注意,就是在撤销操作中进行了新的操作,要怎么办呢?有两种解决方案:

  1. 新操作替换当前快照索引后面所有的数据。还是用刚才的数据 [a, b, c, d] 举例,假设现在进行了两次撤销操作,快照索引变为 1,对应的快照数据为 b,如果这时进行了新的操作,对应的快照数据为 e。那 e 会把 cd 顶掉,现在的快照数据为 [a, b, e]
  2. 不顶掉数据,在原来的快照中新增一条记录。用刚才的例子举例,e 不会把 cd 顶掉,而是在 cd 之前插入,即快照数据变为 [a, b, e, c, d]

我采用的是第一种方案。

7. 吸附

什么是吸附?就是在拖拽组件时,如果它和另一个组件的距离比较接近,就会自动吸附在一起。

吸附的代码大概在 300 行左右,建议自己打开源码文件看(文件路径:src\\components\\Editor\\MarkLine.vue)。这里不贴代码了,主要说说原理是怎么实现的。

标线

在页面上创建 6 条线,分别是三横三竖。这 6 条线的作用是对齐,它们什么时候会出现呢?

  1. 上下方向的两个组件左边、中间、右边对齐时会出现竖线
  2. 左右方向的两个组件上边、中间、下边对齐时会出现横线

具体的计算公式主要是根据每个组件的 xy 坐标和宽度高度进行计算的。例如要判断 ab 两个组件的左边是否对齐,则要知道它们每个组件的 x 坐标;如果要知道它们右边是否对齐,除了要知道 x 坐标,还要知道它们各自的宽度。

// 左对齐的条件
a.x == b.x

// 右对齐的条件
a.x + a.width == b.x + b.width

在对齐的时候,显示标线。

另外还要判断 ab 两个组件是否“足够”近。如果足够近,就吸附在一起。是否足够近要靠一个变量来判断:

diff: 3, // 相距 dff 像素将自动吸附

小于等于 diff 像素则自动吸附。

吸附

吸附效果是怎么实现的呢?

假设现在有 ab 组件,a 组件坐标 xy 都是 0,宽高都是 100。现在假设 a 组件不动,我们正在拖拽 b 组件。当把 b 组件拖到坐标为 x: 0, y: 103 时,由于 103 - 100 <= 3(diff),所以可以判定它们已经接近得足够近。这时需要手动将 b 组件的 y 坐标值设为 100,这样就将 ab 组件吸附在一起了。

优化

在拖拽时如果 6 条标线都显示出来会不太美观。所以我们可以做一下优化,在纵横方向上最多只同时显示一条线。实现原理如下:

  1. a 组件在左边不动,我们拖着 b 组件往 a 组件靠近。
  2. 这时它们最先对齐的是 a 的右边和 b 的左边,所以只需要一条线就够了。
  3. 如果 ab 组件已经靠近,并且 b 组件继续往左边移动,这时就要判断它们俩的中间是否对齐。
  4. b 组件继续拖动,这时需要判断 a 组件的左边和 b 组件的右边是否对齐,也是只需要一条线。

可以发现,关键的地方是我们要知道两个组件的方向。即 ab 两个组件靠近,我们要知道到底 b 是在 a 的左边还是右边。

这一点可以通过鼠标移动事件来判断,之前在讲解拖拽的时候说过,mousedown 事件触发时会记录起点坐标。所以每次触发 mousemove 事件时,用当前坐标减去原来的坐标,就可以判断组件方向。例如 x 方向上,如果 b.x - a.x 的差值为正,说明是 b 在 a 右边,否则为左边。

// 触发元素移动事件,用于显示标线、吸附功能
// 后面两个参数代表鼠标移动方向
// currY - startY > 0 true 表示向下移动 false 表示向上移动
// currX - startX > 0 true 表示向右移动 false 表示向左移动
eventBus.$emit('move', this.$el, currY - startY > 0, currX - startX > 0)

8. 组件属性设置

每个组件都有一些通用属性和独有的属性,我们需要提供一个能显示和修改属性的地方。

// 每个组件数据大概是这样
{
    component: 'v-text', // 组件名称,需要提前注册到 Vue
    label: '文字', // 左侧组件列表中显示的名字
    propValue: '文字', // 组件所使用的值
    icon: 'el-icon-edit', // 左侧组件列表中显示的名字
    animations: [], // 动画列表
    events: {}, // 事件列表
    style: { // 组件样式
        width: 200,
        height: 33,
        fontSize: 14,
        fontWeight: 500,
        lineHeight: '',
        letterSpacing: 0,
        textAlign: '',
        color: '',
    },
}

我定义了一个 AttrList 组件,用于显示每个组件的属性。

<template>
    <div class="attr-list">
        <el-form>
            <el-form-item v-for="(key, index) in styleKeys" :key="index" :label="map[key]">
                <el-color-picker v-if="key == 'borderColor'" v-model="curComponent.style[key]"></el-color-picker>
                <el-color-picker v-else-if="key == 'color'" v-model="curComponent.style[key]"></el-color-picker>
                <el-color-picker v-else-if="key == 'backgroundColor'" v-model="curComponent.style[key]"></el-color-picker>
                <el-select v-else-if="key == 'textAlign'" v-model="curComponent.style[key]">
                    <el-option
                        v-for="item in options"
                        :key="item.value"
                        :label="item.label"
                        :value="item.value"
                    ></el-option>
                </el-select>
                <el-input type="number" v-else v-model="curComponent.style[key]" />
            </el-form-item>
            <el-form-item label="内容" v-if="curComponent && curComponent.propValue && !excludes.includes(curComponent.component)">
                <el-input type="textarea" v-model="curComponent.propValue" />
            </el-form-item>
        </el-form>
    </div>
</template>

代码逻辑很简单,就是遍历组件的 style 对象,将每一个属性遍历出来。并且需要根据具体的属性用不同的组件显示出来,例如颜色属性,需要用颜色选择器显示;数值类的属性需要用 type=number 的 input 组件显示等等。

为了方便用户修改属性值,我使用 v-model 将组件和值绑定在一起。

9. 预览、保存代码

预览和编辑的渲染原理是一样的,区别是不需要编辑功能。所以只需要将原先渲染组件的代码稍微改一下就可以了。

<!--页面组件列表展示-->
<Shape v-for="(item, index) in componentData"
    :defaultStyle="item.style"
    :style="getShapeStyle(item.style, index)"
    :key="item.id"
    :active="item === curComponent"
    :element="item"
    :zIndex="index"
>
    <component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    />
</Shape>

经过刚才的介绍,我们知道 Shape 组件具备了拖拽、放大缩小的功能。现在只需要将 Shape 组件去掉,外面改成套一个普通的 DIV 就可以了(其实不用这个 DIV 也行,但为了绑定事件这个功能,所以需要加上)。

<!--页面组件列表展示-->
<div v-for="(item, index) in componentData" :key="item.id">
    <component
        class="component"
        :is="item.component"
        :style="getComponentStyle(item.style)"
        :propValue="item.propValue"
    />
</div>

保存代码的功能也特别简单,只需要保存画布上的数据 componentData 即可。保存有两种选择:

  1. 保存到服务器
  2. 本地保存

在 DEMO 上我使用的 localStorage 保存在本地。

10. 绑定事件

每个组件有一个 events 对象,用于存储绑定的事件。目前我只定义了两个事件:

  • alert 事件
  • redirect 事件
// 编辑器自定义事件
const events = {
    redirect(url) {
        if (url) {
            window.location.href = url
        }
    },

    alert(msg) {
        if (msg) {
            alert(msg)
        }
    },
}

const mixins = {
    methods: events,
}

const eventList = [
    {
        key: 'redirect',
        label: '跳转事件',
        event: events.redirect,
        param: '',
    },
    {
        key: 'alert',
        label: 'alert 事件',
        event: events.alert,
        param: '',
    },
]

export {
    mixins,
    events,
    eventList,
}

不过不能在编辑的时候触发,可以在预览的时候触发。

添加事件

通过 v-for 指令将事件列表渲染出来:

<el-tabs v-model="eventActiveName">
    <el-tab-pane v-for="item in eventList" :key="item.key" :label="item.label" :name="item.key" style="padding: 0 20px">
        <el-input v-if="item.key == 'redirect'" v-model="item.param" type="textarea" placeholder="请输入完整的 URL" />
        <el-input v-if="item.key == 'alert'" v-model="item.param" type="textarea" placeholder="请输入要 alert 的内容" />
        <el-button style="margin-top: 20px;" @click="addEvent(item.key, item.param)">确定</el-button>
    </el-tab-pane>
</el-tabs>

选中事件时将事件添加到组件的 events 对象。

触发事件

预览或真正渲染页面时,也需要在每个组件外面套一层 DIV,这样就可以在 DIV 上绑定一个点击事件,点击时触发我们刚才添加的事件。

<template>
    <div @click="handleClick">
        <component
            class="conponent"
            :is="config.component"
            :style="getStyle(config.style)"
            :propValue="config.propValue"
        />
    </div>
</template>
handleClick() {
    const events = this.config.events
    // 循环触发绑定的事件
    Object.keys(events).forEach(event => {
        this[event](events[event])
    })
}

11. 绑定动画

动画和事件的原理是一样的,先将所有的动画通过 v-for 指令渲染出来,然后点击动画将对应的动画添加到组件的 animations 数组里。同事件一样,执行的时候也是遍历组件所有的动画并执行。

为了方便,我们使用了 animate.css 动画库。

// main.js
import '@/styles/animate.css'

现在我们提前定义好所有的动画数据:

export default [
    {
        label: '进入',
        children: [
            { label: '渐显', value: 'fadeIn' },
            { label: '向右进入', value: 'fadeInLeft' },
            { label: '向左进入', value: 'fadeInRight' },
            { label: '向上进入', value: 'fadeInUp' },
            { label: '向下进入', value: 'fadeInDown' },
            { label: '向右长距进入', value: 'fadeInLeftBig' },
            { label: '向左长距进入', value: 'fadeInRightBig' },
            { label: '向上长距进入', value: 'fadeInUpBig' },
            { label: '向下长距进入', value: 'fadeInDownBig' },
            { label: '旋转进入', value: 'rotateIn' },
            { label: '左顺时针旋转', value: 'rotateInDownLeft' },
            { label: '右逆时针旋转', value: 'rotateInDownRight' },
            { label: '左逆时针旋转', value: 'rotateInUpLeft' },
            { label: '右逆时针旋转', value: 'rotateInUpRight' },
            { label: '弹入', value: 'bounceIn' },
            { label: '向右弹入', value: 'bounceInLeft' },
            { label: '向左弹入', value: 'bounceInRight' },
            { label: '向上弹入', value: 'bounceInUp' },
            { label: '向下弹入', value: 'bounceInDown' },
            { label: '光速从右进入', value: 'lightSpeedInRight' },
            { label: '光速从左进入', value: 'lightSpeedInLeft' },
            { label: '光速从右退出', value: 'lightSpeedOutRight' },
            { label: '光速从左退出', value: 'lightSpeedOutLeft' },
            { label: 'Y轴旋转', value: 'flip' },
            { label: '中心X轴旋转', value: 'flipInX' },
            { label: '中心Y轴旋转', value: 'flipInY' },
            { label: '左长半径旋转', value: 'rollIn' },
            { label: '由小变大进入', value: 'zoomIn' },
            { label: '左变大进入', value: 'zoomInLeft' },
            { label: '右变大进入', value: 'zoomInRight' },
            { label: '向上变大进入', value: 'zoomInUp' },
            { label: '向下变大进入', value: 'zoomInDown' },
            { label: '向右滑动展开', value: 'slideInLeft' },
            { label: '向左滑动展开', value: 'slideInRight' },
            { label: '向上滑动展开', value: 'slideInUp' },
            { label: '向下滑动展开', value: 'slideInDown' },
        ],
    },
    {
        label: '强调',
        children: [
            { label: '弹跳', value: 'bounce' },
            { label: '闪烁', value: 'flash' },
            { label: '放大缩小', value: 'pulse' },
            { label: '放大缩小弹簧', value: 'rubberBand' },
            { label: '左右晃动', value: 'headShake' },
            { label: '左右扇形摇摆', value: 'swing' },
            { label: '放大晃动缩小', value: 'tada' },
            { label: '扇形摇摆', value: 'wobble' },
            { label: '左右上下晃动', value: 'jello' },
            { label: 'Y轴旋转', value: 'flip' },
        ],
    },
    {
        label: '退出',
        children: [
            { label: '渐隐', value: 'fadeOut' },
            { label: '向左退出', value: 'fadeOutLeft' },
            { label: '向右退出', value: 'fadeOutRight' },
            { label: '向上退出', value: 'fadeOutUp' },
            { label: '向下退出', value: 'fadeOutDown' },
            { label: '向左长距退出', value: 'fadeOutLeftBig' },
            { label: '向右长距退出', value: 'fadeOutRightBig' },
            { label: '向上长距退出', value: 'fadeOutUpBig' },
            { label: '向下长距退出', value: 'fadeOutDownBig' },
            { label: '旋转退出', value: 'rotateOut' },
            { label: '左顺时针旋转', value: 'rotateOutDownLeft' },
            { label: '右逆时针旋转', value: 'rotateOutDownRight' },
            { label: '左逆时针旋转', value: 'rotateOutUpLeft' },
            { label: '右逆时针旋转', value: 'rotateOutUpRight' },
            { label: '弹出', value: 'bounceOut' },
            { label: '向左弹出', value: 'bounceOutLeft' },
            { label: '向右弹出', value: 'bounceOutRight' },
            { label: '向上弹出', value: 'bounceOutUp' },
            { label: '向下弹出', value: 'bounceOutDown' },
            { label: '中心X轴旋转', value: 'flipOutX' },
            { label: '中心Y轴旋转', value: 'flipOutY' },
            { label: '左长半径旋转', value: 'rollOut' },
            { label: '由小变大退出', value: 'zoomOut' },
            { label: '左变大退出', value: 'zoomOutLeft' },
            { label: '右变大退出', value: 'zoomOutRight' },
            { label: '向上变大退出', value: 'zoomOutUp' },
            { label: '向下变大退出', value: 'zoomOutDown' },
            { label: '向左滑动收起', value: 'slideOutLeft' },
            { label: '向右滑动收起', value: 'slideOutRight' },
            { label: '向上滑动收起', value: 'slideOutUp' },
            { label: '向下滑动收起', value: 'slideOutDown' },
        ],
    },
]

然后用 v-for 指令渲染出来动画列表。

添加动画

<el-tabs v-model="animationActiveName">
    <el-tab-pane v-for="item in animationClassData" :key="item.label" :label="item.label" :name="item.label">
        <el-scrollbar class="animate-container">
            <div
                class="animate"
                v-for="(animate, index) in item.children"
                :key="index"
                @mouseover="hoverPreviewAnimate = animate.value"
                @click="addAnimation(animate)"
            >
                <div :class="[hoverPreviewAnimate === animate.value && animate.value + ' animated']">
                    {{ animate.label }}
                </div>
            </div>
        </el-scrollbar>
    </el-tab-pane>
</el-tabs>

点击动画将调用 addAnimation(animate) 将动画添加到组件的 animations 数组。

触发动画

运行动画的代码:

export default async function runAnimation($el, animations = []) {
    const play = (animation) => new Promise(resolve => {
        $el.classList.add(animation.value, 'animated')
        const removeAnimation = () => {
            $el.removeEventListener('animationend', removeAnimation)
            $el.removeEventListener('animationcancel', removeAnimation)
            $el.classList.remove(animation.value, 'animated')
            resolve()
        }
            
        $el.addEventListener('animationend', removeAnimation)
        $el.addEventListener('animationcancel', removeAnimation)
    })

    for (let i = 0, len = animations.length; i < len; i++) {
        await play(animations[i])
    }
}

运行动画需要两个参数:组件对应的 DOM 元素(在组件使用 this.$el 获取)和它的动画数据 animations。并且需要监听 animationend 事件和 animationcancel 事件:一个是动画结束时触发,一个是动画意外终止时触发。

利用这一点再配合 Promise 一起使用,就可以逐个运行组件的每个动画了。

12. 导入 PSD

由于时间关系,这个功能我还没做。现在简单的描述一下怎么做这个功能。那就是使用 psd.js 库,它可以解析 PSD 文件。

使用 psd 库解析 PSD 文件得出的数据如下:

{ children: 
   [ { type: 'group',
       visible: false,
       opacity: 1,
       blendingMode: 'normal',
       name: 'Version D',
       left: 0,
       right: 900,
       top: 0,
       bottom: 600,
       height: 600,
       width: 900,
       children: 
        [ { type: 'layer',
            visible: true,
            opacity: 1,
            blendingMode: 'normal',
            name: 'Make a change and save.',
            left: 275,
            right: 636,
            top: 435,
            bottom: 466,
            height: 31,
            width: 361,
            mask: {},
            text: 
             { value: 'Make a change and save.',
               font: 
                { name: 'HelveticaNeue-Light',
                  sizes: [ 33 ],
                  colors: [ [ 85, 96, 110, 255 ] ],
                  alignment: [ 'center' ] },
               left: 0,
               top: 0,
               right: 0,
               bottom: 0,
               transform: { xx: 1, xy: 0, yx: 0, yy: 1, tx: 456, ty: 459 } },
            image: {} } ] } ],
    document: 
       { width: 900,
         height: 600,
         resources: 
          { layerComps: 
             [ { id: 692243163, name: 'Version A', capturedInfo: 1 },
               { id: 725235304, name: 'Version B', capturedInfo: 1 },
               { id: 730932877, name: 'Version C', capturedInfo: 1 } ],
            guides: [],
            slices: [] } } }

从以上代码可以发现,这些数据和 css 非常像。根据这一点,只需要写一个转换函数,将这些数据转换成我们组件所需的数据,就能实现 PSD 文件转成渲染组件的功能。目前 quark-h5luban-h5 都是这样实现的 PSD 转换功能。

13. 手机模式

由于画布是可以调整大小的,我们可以使用 iphone6 的分辨率来开发手机页面。

这样开发出来的页面也可以在手机下正常浏览,但可能会有样式偏差。因为我自定义的三个组件是没有做适配的,如果你需要开发手机页面,那自定义组件必须使用移动端的 UI 组件库。或者自己开发移动端专用的自定义组件。

总结

由于 DEMO 的代码比较多,所以在讲解每一个功能点时,我只把关键代码贴上来。所以大家会发现 DEMO 的源码和我贴上来的代码会有些区别,请不必在意。

另外,DEMO 的样式也比较简陋,主要是最近事情比较多,没太多时间写好看点,请见谅。

参考资料

查看原文

赞 63 收藏 48 评论 2

独钓寒江雪 收藏了文章 · 2020-12-21

前端装逼技巧 108 式(三)—— 冇得感情的API调用工程师

敲一夜代码,流两行老泪;用三种语言,唯四肢受罪;待五更鸡鸣,遇骤雨初歇;遂登门而去,伫十里长亭;欲望穿泪眼,无如意郎君;借微薄助力,愿寻得佳偶;成比翼双鸟,乃畅想云端;卷情网之内,做爬虫抓取;为连理桂枝,容数据分析;思千里子规,助框广天地;念茫茫人海,该如何寻觅?

系列文章发布汇总:

文章风格所限,引用资料部分,将在对应小节末尾标出。

第三十七式:茫然一顾眼前亮,懵懂宛如在梦中 —— "123​4".length === 5 ?这一刻,我感受到了眼睛的背叛和侮辱

  • 复制以下代码到浏览器控制台:
console.log('123​4'.length === 5); // true

12345

  哈哈,是不是有种被眼睛背叛的感觉?其实这就是所谓的零宽空格(Zero Width Space,简称“ZWSP”),零宽度字符是不可见的非打印字符,它用于打断长英文单词或长阿拉伯数字,以便于换行显示,否则长英文单词和长阿拉伯数字会越过盒模型的边界,常见于富文本编辑器,用于格式隔断。

  • 探究一下上面代码的玄机:
const common = '1234';
const special = '123​4';
console.log(common.length); // 4
console.log(special.length); // 5
console.log(encodeURIComponent(common)); // 1234
console.log(encodeURIComponent(special)); // 123%E2%80%8B4
// 把上面中间特殊字符部分进行解码
console.log(decodeURIComponent('%E2%80%8B')); // (空)

const otherSpecial = '123\u200b4'; // 或者"123\u{200b}4"
console.log(otherSpecial); // 1234
console.log(otherSpecial.length, common === special, special === otherSpecial); // 5 false true
  • 在 HTML 中使用零宽度空格(在 HTML 中,零宽度空格与<wbr>等效):
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <!-- &#8203; 和 <wbr /> 是零宽空格在html中的两种表示 -->
    <div>abc&#8203;def</div>
    <div>abc<wbr />def</div>
  </body>
</html>
ESLint 有一条禁止不规则的空白 (no-irregular-whitespace)的规则,防止代码里面误拷贝了一些诸如零宽空格类的空格,以免造成一些误导。
拓展:我们经常在 html 中使用的&nbsp;全称是No-Break SPace,即不间断空格,当 HTML 有多个连续的普通空格时,浏览器在渲染时只会渲染一个空格,而使用这个不间断空格,可以禁止浏览器合并空格。常用于富文本编辑器之中,当我们在富文本编辑器连续敲下多个空格时,最后输出的内容便会带有很多不间断空格。
参考资料:常见空格一览 - 李银城 | 什么是零宽度空格 | 维基百科-空格

第三十八式:如何禁止网页复制粘贴

  对于禁止网页复制粘贴,也许你并不陌生。一些网页是直接禁止复制粘贴;一些网页,则是要求登陆后才可复制粘贴;还有一些网站,复制粘贴时会带上网站的相关来源标识信息。

  • 如何禁止网页复制粘贴
const html = document.querySelector('html');
html.oncopy = () => {
  alert('牛逼你复制我呀');
  return false;
};
html.onpaste = () => false;
  • 在复制时做些别的操作,比如跳转登陆页面
const html = document.querySelector('html');
html.oncopy = (e) => {
  console.log(e);
  // 比如指向百度或者登陆页
  // window.location.href='http://www.baidu.com';
};
html.onpaste = (e) => {
  console.log(e);
};
  • 如何使用 js 设置/获取剪贴板内容
//设置剪切板内容
document.addEventListener('copy', () => {
  const clipboardData =
    event.clipboardData || event.originalEvent?.clipboardData;
  clipboardData?.setData('text/plain', '不管复制什么,都是我!');
  event.preventDefault();
});

//获取剪切板的内容
document.addEventListener('paste', () => {
  const clipboardData =
    event.clipboardData || event.originalEvent?.clipboardData;
  const text = clipboardData?.getData('text');
  console.log(text);
  event.preventDefault();
});
  • 有什么用

    • 对于注册输入密码等需要输入两次相同内容的场景,应该是需要禁止粘贴的,这时候就可以禁止对应输入框的复制粘贴动作。
    • 登陆才能复制。很多网站上的页面内容是不允许复制的,这样可以防止用户或者程序恶意的去抓取页面数据。
Tips:页面禁止复制,而你又想复制,怎么办:方法一,在浏览器设置 -> 隐私设置和安全性 -> 禁用JavaScript;方法二,审查元素,在Elements中找到对应DOM,进行复制。

参考资料:Clipboard API and events | Document.execCommand()

第三十九式:function.length指代什么? —— 认识柯里化和JS 函数重载

  在函数式编程里,有几个比较重要的概念:函数的合成、柯里化和函子。其中柯里化(Currying),是指把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家 Haskell Curry 命名的,但是它是 Moses Schnfinkel 和 Gottlob Frege 发明的。

  lodash 实现了_.curry函数,_.curry函数接收一个函数作为参数,返回新的柯里化(curry)函数。调用新的柯里化函数时,当传递的参数个数小于柯里化函数要求的参数时,返回一个接收剩余参数的函数,当传递的参数达到柯里化函数要求时,返回结果。那么,_.curry函数是如何判断传递的参数是否到达要求的呢?我们不妨先看看下面的例子:

function func(a, b, c) {
  console.log(func.length, arguments.length);
}
func(1); // 3  1
  • 看看 MDN 的解释:

    • length 是函数对象的一个属性值,指该函数有多少个必须要传入的参数,那些已定义了默认值的参数不算在内,比如 function(x = 0)的 length 是 0。即形参的数量仅包括第一个具有默认值之前的参数个数。
    • 与之对比的是, arguments.length 是函数被调用时实际传参的个数。
  • 实现 lodash curry 化函数
// 模拟实现 lodash 中的 curry 方法
function curry(func) {
  return function curriedFn(...args) {
    // 判断实参和形参的个数
    if (args.length < func.length) {
      return function () {
        return curriedFn(...args.concat(Array.from(arguments)));
      };
    }
    return func(...args);
  };
}

function getSum(a, b, c) {
  return a + b + c;
}

const curried = curry(getSum);

console.log(curried(1, 2, 3));
console.log(curried(1)(2, 3));
console.log(curried(1, 2)(3));
  • JS 函数重载

  函数重载,就是函数名称一样,但是允许有不同输入,根据输入的不同,调用不同的函数,返回不同的结果。JS 里默认是没有函数重载的,但是有了Function.length属性和arguments.length,我们便可简单的通过if…else或者switch来完成 JS 函数重载了。

function overLoading() {
  // 根据arguments.length,对不同的值进行不同的操作
  switch (arguments.length) {
    case 0 /*操作1的代码写在这里*/:
      break;
    case 1 /*操作2的代码写在这里*/:
      break;
    case 2: /*操作3的代码写在这里*/
  }
}

  更高级的函数重载,请参考 jQuery 之父 John Resig 的JavaScript Method Overloading, 这篇文章里,作者巧妙地利用闭包,实现了 JS 函数的重载。

参考资料:浅谈 JavaScript 函数重载 | JavaScript Method Overloading | 【译】JavaScript 函数重载 - Fundebug | Function.length | 函数式编程入门教程 - 阮一峰

第四十式:["1","7","11"].map(parseInt)为什么会返回[1,NaN,3]?

  • map 返回 3 个参数,item,index,Array,console.log可以接收任意个参数,所以[1,7,11].map(console.log)打印:

parseInt

  • parseInt 接受两个参数:string,radix,其中 radix 默认为 10;
  • 那么,每次调用 parseInt,相当于:parseInt(item,index,Array),map 传递的第三个参数 Array 会被忽略。index 为 0 时,parseInt(1,0),radix 取默认值 10;parseInt(7,1)中,7 在 1 进制中不存在,所以返回”NaN“;parseInt(11,2),2 进制中 11 刚好是十进制中的 3。
参考:JS 中为啥 ['1', '7', '11'].map(parseInt) 返回 [1, NaN, 3]

第四十一式:iframe 间数据传递,postMessage 可以是你的选择

  平时开发中,也许我们会遇到需要在非同源站点、iframe 间传递数据的情况,这个时候,我们可以使用 postMessage 完成数据的传递。
  window.postMessage() 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的模数 Document.domain 设置为相同的值) 时,这两个脚本才能相互通信(即同源)。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

// 页面1 触发事件,发送数据
top.postMessage(data, '*');
// window  当前所在iframe
// parent  上一层iframe
// top     最外层iframe

//页面2 监听message事件
useEffect(() => {
  const listener = (ev) => {
    console.log(ev, ev.data);
  };
  window.addEventListener('message', listener);
  return () => {
    window.removeEventListener('message', listener);
  };
}, []);

注意:

  • postMessage第二个参数 targetOrigin 用来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。
  • 如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的 targetOrigin,而不是"*"。
  • 不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。
参考资料:window.postMessage

第四十二式:薛定谔的 X —— 有趣的let x = x

  薛定谔的猫(英文名称:Erwin Schrödinger's Cat)是奥地利著名物理学家薛定谔提出的一个思想实验,是指将一只猫关在装有少量镭和氰化物的密闭容器里。镭的衰变存在几率,如果镭发生衰变,会触发机关打碎装有氰化物的瓶子,猫就会死;如果镭不发生衰变,猫就存活。根据量子力学理论,由于放射性的镭处于衰变和没有衰变两种状态的叠加,猫就理应处于死猫和活猫的叠加状态。这只既死又活的猫就是所谓的“薛定谔猫”。

  JS 引入 let 和 const 之后,也出现了一种有趣的现象:

<!-- 可以拷贝下面的代码,放的一个html文件中,然后使用浏览器打开,查看控制台 -->
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      let x = x;
    </script>
    <script>
      x = 2;
      console.log(x);
    </script>
  </body>
</html>

specx

  上面的代码里,我们在第一个 script 里引入写了let x = x;,就导致在其他 script 下都无法在全局作用域下使用 x 变量了(无论是对 x 进行赋值、取值,还是声明,都不行)。也就是说现在 x 处于一种“既被定义了,又没被定义”的中间状态。

  这个问题说明:如果 let x 的初始化过程失败了,那么:

  • x 变量就将永远处于 created 状态。
  • 你无法再次对 x 进行初始化(初始化只有一次机会,而那次机会你失败了)。
  • 由于 x 无法被初始化,所以 x 永远处在暂时死区(也就是盗梦空间里的 limbo)!
  • 有人会觉得 JS 坑,怎么能出现这种情况;其实问题不大,因为此时代码已经报错了,后面的代码想执行也没机会。
参考资料:JS 变量封禁大法:薛定谔的 X

第四十三式:聊聊前端错误处理

一个 React-dnd 引出的前端错误处理

  年初的时候,笔者曾做过一个前端错误处理的笔记,事情是这样的:

  项目中某菜单定义的页面因有拖拽的需求,就引入了React DnD来完成这一工作;随着业务的更新迭代,部分列表页面又引入了自定义列的功能,可以通过拖动来对列进行排序,后面就发现在某些页面上,试图打开自定义列的弹窗时,页面就崩溃白屏了,控制台会透出错误:'Cannot have two HTML5 backends at the same time.'。在排查问题的时候,查看源码发现:

// ...
value: function setup() {
  if (this.window === undefined) {
    return;
  }
  if (this.window.__isReactDndBackendSetUp) {
    throw new Error('Cannot have two HTML5 backends at the same time.');
  }
  this.window.__isReactDndBackendSetUp = true;
  this.addEventListeners(this.window);
}
// ...

  也就是说,react-dnd-html5-backend在创建新的实例前会通过window.__isReactDndBackendSetUp的全局变量来判断是否已经存在一个可拖拽组件,如果有的话,就直接报错,而由于项目里对应组件没有相应的错误处理逻辑,抛出的 Error 异常层层上传到 root,一直没有被捕获和处理,最终导致页面崩溃。其实在当时的业务场景下,这个问题比较好解决,因为菜单定义页面没有自定义列的需求,而其他页面自定义列又是通过弹窗展示的,所以不要忘了给自定义列弹窗设置 destroyOnClose 属性(关闭销毁)即可。为了避免项目中因为一些错误导致系统白屏,在项目中,我们应该合理使用错误处理。

前端错误处理的方法

1、Error Boundaries

  如何使一个 React 组件变成一个“Error Boundaries”呢?只需要在组件中定义个新的生命周期函数——componentDidCatch(error, info):

error: 这是一个已经被抛出的错误;info:这是一个 componentStack key。这个属性有关于抛出错误的组件堆栈信息。
// ErrorBoundary实现
class ErrorBoundary extends React.Component {
  state = { hasError: false };

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

ErrorBoundary 使用:

// ErrorBoundary使用
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>
Erro Boundaries 本质上也是一个组件,通过增加了新的生命周期函数 componentDidCatch 使其变成了一个新的组件,这个特殊组件可以捕获其子组件树中的 js 错误信息,输出错误信息或者在报错条件下,显示默认错误页。注意一个 Error Boundaries 只能捕获其子组件中的 js 错误,而不能捕获其组件本身的错误和非子组件中的 js 错误。

  但是 Error Boundaries 也不是万能的,下面我们来看哪些情况下不能通过 Error Boundaries 来 catch{}错误:

  • 组件内部的事件处理函数,因为 Error Boundaries 处理的仅仅是 Render 中的错误,而 Hander Event 并不发生在 Render 过程中。
  • 异步函数中的异常 Error Boundaries 不能 catch,比如 setTimeout 或者 setInterval 、requestAnimationFrame 等函数中的异常。
  • 服务器端的 rendering
  • 发生在 Error Boundaries 组件本身的错误

2、componentDidCatch()生命周期函数:

  componentDidCatch 是一个新的生命周期函数,当组件有了这个生命周期函数,就成为了一个 Error Boundaries。

3、try/catch 模块

  Error Boundaries 仅仅抛出了子组件的错误信息,并且不能抛出组件中的事件处理函数中的异常。(因为 Error Boundaries 仅仅能保证正确的 render,而事件处理函数并不会发生在 render 过程中),我们需要用 try/catch 来处理事件处理函数中的异常。

try/catch 只能捕获到同步的运行时错误,对语法和异步错误却无能为力。

4、window.onerror

  当 JS 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror()。

在实际使用过程中,onerror 主要是来捕获预料之外的错误,而 try-catch 则是用来在可预见情况下监控特定的错误,两者结合使用更加高效。
/**
 * @param {String}  message    错误信息
 * @param {String}  source    出错文件
 * @param {Number}  lineno    行号
 * @param {Number}  colno    列号
 * @param {Object}  error  Error对象(对象)
 */
window.onerror = function (message, source, lineno, colno, error) {
  console.log('捕获到异常:', { message, source, lineno, colno, error });
  // window.onerror 函数只有在返回 true 的时候,异常才不会向上抛出,否则即使是知道异常的发生控制台还是会显示 Uncaught Error: xxxxx。
  //  return true;
};

5、window.addEventListener

  主要用于静态资源加载异常捕获。

6、Promise Catch

  try..catch..虽然能捕获错误,但是不能捕获异步的异常;promise碰到then,也就是resolve或者reject的时候是异步的,所以try...catch对它是没有用的。Promise.prototype.catch 方法是用于指定发生错误时的回调函数。

7、unhandledrejection

  当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在 window 下,但也可能发生在 Worker 中。 unhandledrejection 继承自 PromiseRejectionEvent,而 PromiseRejectionEvent 又继承自 Event。因此 unhandledrejection 含有 PromiseRejectionEvent 和 Event 的属性和方法。

总结

  前端组件/项目中,需要有适当的错误处理过程,否则出现错误,层层上传,没有进行捕获,就会导致页面挂掉。

第四十四式:不做工具人 —— 使用 nodejs 根据配置自动生成文件

  笔者在工作中有一个需求是搭建一个 BFF 层项目,实现对每一个接口的权限控制和转发到后端底层接口。因为 BFF 层接口逻辑较少,70%情况下都只是实现一个转发,所以每个文件相似度较高,但因为每个 API 需单独控制权限,所以 API 文件又必须存在,所以使用 nodejs 编写 API 自动化生成脚本,避免进行大量的手动创建文件和复制修改的操作,示例如下:

  • 编写自动生成文件的脚本:
// auto.js
const fs = require('fs');
const path = require('path');
const config = require('./apiConfig'); // json配置文件,格式见下面注释内容
// config的格式如下:
// [
//     {
//         filename: 'querySupplierInfoForPage.js',
//         url: '/supplier/rest/v1/supplier/querySupplierInfoForPage',
//         comment: '分页查询供应商档案-主信息',
//     },
// ]

// 验证数量是否一致
// 也可以在此做一些其他的验证,需要验证时调用这个函数即可
function verify() {
  console.log(
    config.length,
    fs.readdirSync(path.join(__dirname, '/server/api')).length
  );
}

// 生成文件
function writeFileAuto(filePath, item) {
  fs.writeFileSync(
    filePath,
    `/**
* ${item.comment}
*/
const { Controller, Joi } = require('ukoa');

module.exports = class ${item.filename.split('.')[0]} extends Controller {
    init() {
        this.schema = {
            Params: Joi.object().default({}).notes('参数'),
            Action: Joi.string().required().notes('Action')
        };
    }

    // 执行函数体
    async main() {
        const { http_supply_chain } = this.ctx.galaxy;
        const [data] = await http_supply_chain("${
          item.url
        }", this.params.Params, { throw: true });
        return this.ok = data.obj;
    }
};
`
  );
}

function exec() {
  config.forEach((item) => {
    var filePath = path.join(__dirname, '/server/api/', item.filename);
    fs.exists(filePath, function (exists) {
      if (exists) {
        // 已存在的文件就不要重复生成了,因为也许你已经对已存在的文件做了特殊逻辑处理
        //(毕竟只有70%左右的API是纯转发,还有30%左右有自己的处理逻辑)
        console.log(`文件${item.filename}已存在`);
      } else {
        console.log(`创建文件:${item.filename}`);
        writeFileAuto(filePath, item);
      }
    });
  });
}

exec();
  • 执行脚本,生成文件如下:node auto.js
// querySupplierInfoForPage.js
/**
 * 分页查询供应商档案-主信息
 */
const { Controller, Joi } = require('ukoa');

module.exports = class querySupplierInfoForPage extends (
  Controller
) {
  init() {
    this.schema = {
      Params: Joi.object().default({}).notes('参数'),
      Action: Joi.string().required().notes('Action'),
    };
  }

  // 执行函数体
  async main() {
    const { http_supply_chain } = this.ctx.galaxy;
    const [
      data,
    ] = await http_supply_chain(
      '/supplier/rest/v1/supplier/querySupplierInfoForPage',
      this.params.Params,
      { throw: true }
    );
    return (this.ok = data.obj);
  }
};

  此处只是抛砖引玉,结合具体业务场景,也许你会为 nodejs 脚本找到更多更好的用法,为前端赋能。

第四十五式:明明元素存在,我的document.getElementsByTagName('video')却获取不到?

  • 使用 Chrome 浏览器在线看视频的时候,有些网站不支持倍速播放;有的网站只支持 1.5 和 2 倍速,但是自己更喜欢 1.75 倍;又或者有些网站需要会员才能倍速播放(比如某盘),一般我们可以通过安装相应的浏览器插件解决,如果不愿意安装插件,也可以使用类似document.getElementsByTagName('video')[0].playbackRate = 1.75(1.75 倍速)的方式实现倍速播放,这个方法在大部分网站上是有效的(当然,如果知道 video 标签的 id 或者 class,通过 id 和 class 来获取元素会更便捷一点),经测试,playbackRate支持的最大倍速 Chrome 下是 16。同时,给playbackRate设置一个小于 1 的值,比如 0.3,可以模拟出类似鬼片的音效
  • 但是在某盘,这种方法却失效了,因为我没有办法获取到 video 元素,审查元素如下:
    videojs

  审查元素时,我们发现了#shadow-root (closed)videojs的存在。也许你还记得,在第六式中我们曾简单探讨过Web Components,其中介绍到attachShadow()方法可以开启 Shadow DOM(这部分 DOM 默认与外部 DOM 隔离,内部任何代码都无法影响外部,避免样式等的相互干扰),隐藏自定义元素的内部实现,我们外部也没法获取到相应元素,如下图所以(点击图片跳转 Web Components 示例代码):

shadow

  是以,我们可以合理推断,某盘的网页视频播放也使用了类似Element.attachShadow()方法进行了元素隐藏,所以我们无法通过document.getElementsByTagName('video')获取到 video 元素。通过阅读videojs 文档发现,可以通过相应 API 实现自定义倍速播放:

videojs.getPlayers('video-player').html5player.tech_.setPlaybackRate(1.666);
参考资料:百度网盘视频倍速播放方法 | videojs 文档 | Element.attachShadow() | 深入理解 Shadow DOM v1

第四十六式:SQL 也可以 if else? —— 不常写 SQL 的我神奇的知识增加了

  在刷 leetcode 的时候遇到一个 SQL 题目627. 变更性别,题目要求如下:

给定一个  salary  表,有 m = 男性 和 f = 女性 的值。交换所有的 f 和 m 值(例如,将所有 f 值更改为 m,反之亦然)。要求只使用一个更新(Update)语句,并且没有中间的临时表。注意,您必只能写一个 Update 语句,请不要编写任何 Select 语句。
  UPDATE salary
    SET
      sex = CASE sex
          WHEN 'm' THEN 'f'
          ELSE 'm'
        END;
参考资料:SQL 之 CASE WHEN 用法详解

第四十七式:庭院深深深几许,杨柳堆烟,帘幕无重数 —— 如何实现深拷贝?

  深拷贝,在前端面试里似乎是一个永恒的话题了,最简单的方法是JSON.stringify()以及JSON.parse(),但是这种方法能正确处理的对象只有 Number, String, Boolean, Array, 扁平对象,不可以拷贝 undefined , function, RegExp 等类型。还有其他一些包括扩展运算符、object.asign、递归拷贝、lodash 库等的实现,网上有很多相关资料和实现,这里不是我们讨论的重点。这次我们来探讨一个新的实现 —— MessageChannel。我们直接看代码:

// 创建一个obj对象,这个对象中有 undefined 和 循环引用
let obj = {
  a: 1,
  b: {
    c: 2,
    d: 3,
  },
  f: undefined,
};
obj.c = obj.b;
obj.e = obj.a;
obj.b.c = obj.c;
obj.b.d = obj.b;
obj.b.e = obj.b.c;

// 深拷贝方法封装
function deepCopy(obj) {
  return new Promise((resolve) => {
    const { port1, port2 } = new MessageChannel();
    port1.postMessage(obj);
    port2.onmessage = (e) => resolve(e.data);
  });
}

// 调用
deepCopy(obj).then((copy) => {
  // 请记住`MessageChannel`是异步的这个前提!
  let copyObj = copy;
  console.log(copyObj, obj);
  console.log(copyObj == obj);
});

  我们发现MessageChannelpostMessage传递的数据也是深拷贝的,这和web workerpostMessage一样。而且还可以拷贝 undefined 和循环引用的对象。简单说,MessageChannel创建了一个通信的管道,这个管道有两个端口,每个端口都可以通过postMessage发送数据,而一个端口只要绑定了onmessage回调方法,就可以接收从另一个端口传过来的数据。

需要说明的一点是:MessageChannel在拷贝有函数的对象时,还是会报错。

参考资料:MessageChannel | MessageChannel 是什么,怎么使用?

第四十八式:换了电脑,如何使用 VSCode 保存插件配置?

  也许每一个冇得感情的 API 调用工程师在使用 VSCode 进行开发时,都有自己的插件、个性化配置以及代码片段等,使用 VSCode 不用登陆,不用注册账号,确实很方便,但这同时也带来一个问题:如果你有多台电脑,比如家里一个、公司一个,都会用来开发;又或者,你离职入职了新的公司。此时,我们就需要从头再次配置一遍 VSCode,包括插件、配置、代码片段,如此反复,也许真的会崩溃。其实 VSCode 提供了 setting sync 插件,来方便我们同步插件配置。具体使用如下:

  • 在 VSCode 中搜索 Settings Sync 并进行安装;
  • 安装后,摁下 Ctrl(mac 为 command)+ Shift + P 打开控制面板,搜索 Sync,选择 Sync: Update/Upload Settings 可以上传你的配置,选择 Sync: Download Settings 会下载远程配置;
  • 如果你之前没有使用过 Settings Sync,在上传配置的时候,会让你在 Github 上创建一个授权码,允许 IDE 在你的 gist 中创建资源;下载远程配置,你可以直接将 gist 的 id 填入。
  • 下载后等待安装,然后重启即可。

  如此以来,我们就可以在多台设备间同步配置了。

参考资料:Settings Sync | VSCode 保存插件配置并使用 gist 管理代码片段

第四十九式:防止对象被篡改,可以试试 Object.seal 和 Object.freeze

  有时候你可能怕你的对象被误改了,所以需要把它保护起来。

  • Object.seal 防止新增和删除属性

  通常,一个对象是可扩展的(可以添加新的属性)。使用Object.seal()方法封闭一个对象会让这个对象变的不能添加新属性,且所有已有属性会变的不可配置。属性不可配置的效果就是属性变的不可删除,以及一个数据属性不能被重新定义成为访问器属性,或者反之。当前属性的值只要原来是可写的就可以改变。尝试删除一个密封对象的属性或者将某个密封对象的属性从数据属性转换成访问器属性,结果会静默失败或抛出 TypeError。

数据属性包含一个数据值的位置,在这个位置可以读取和写入值。访问器属性不包含数据值,它包含一对 getter 和 setter 函数。当读取访问器属性时,会调用 getter 函数并返回有效值;当写入访问器属性时,会调用 setter 函数并传入新值,setter 函数负责处理数据。
const person = {
  name: 'jack',
};
Object.seal(person);
delete person.name;
console.log(person); // {name: "jack"}
  • Object.freeze 冻结对象

  Object.freeze() 方法可以冻结一个对象。一个被冻结的对象再也不能被修改;冻结了一个对象则不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值。此外,冻结一个对象后该对象的原型也不能被修改。freeze() 返回和传入的参数相同的对象。

const obj = {
  prop: 42,
};
Object.freeze(obj);
obj.prop = 33;
// Throws an error in strict mode
console.log(obj.prop);
// expected output: 42
Tips:Object.freeze浅冻结,即只冻结一层,要使对象不可变,需要递归冻结每个类型为对象的属性(深冻结)。使用Object.freeze()冻结的对象中的现有属性值是不可变的。用Object.seal()密封的对象可以改变其现有属性值。同时可以使用 Object.isFrozenObject.isSealedObject.isExtensible 判断当前对象的状态。
  • Object.defineProperty 冻结单个属性:设置 enumable/writable 为 false,那么这个属性将不可遍历和写。
参考资料:JS 高级技巧 | javascript 的数据属性和访问器属性 | Object.freeze() | Object.seal() | 深入浅出 Object.defineProperty()

第五十式:不随机的随机数 —— 我们都知道Math.random是伪随机的,那如何得到密码学安全的随机数

  在 JavaScript 中产生随机数的方式是调用 Math.random,这个函数返回[0, 1)之间的数字,我们通过对Math.random的包装处理,可以得到我们想要的各种随机值。

  • 怎么实现一个随机数发生器
// from stackoverflow
// 下面的实现还是很随机的
let seed = 1;
function random() {
  let x = Math.sin(seed++) * 10000;
  return x - Math.floor(x);
}

  随机数发生器函数需要一个种子 seed,每次调用 random 函数的时候种子都会发生变化。因为random()是一个没有输入的函数,不管执行多少次,其运行结果都是一样的,所以需要有一个不断变化的入参,这个入参就叫种子,每运行一次种子就会发生一次变化。所以我们可以借助以上思路实现自己的随机数发生器(或许有些场合,我们不必管他是不是真的是随机的,再或者就是要让他不随机呢)。

  • 为什么说 Math.random 是不安全的呢?

  V8 源码显示 Math.random 种子的可能个数为 2 ^ 64, 随机算法相对简单,只是保证尽可能的随机分布。我们知道扑克牌有 52 张,总共有 52! = 2 ^ 226 种组合,如果随机种子只有 2 ^ 64 种可能,那么可能会有大量的组合无法出现。

  从 V8 里 Math.random 的实现逻辑来看,每次会一次性产生 128 个随机数,并放到 cache 里面,供后续使用,当 128 个使用完了再重新生成一批随机数。所以 Math.random 的随机数具有可预测性,这种由算法生成的随机数也叫伪随机数。只要种子确定,随机算法也确定,便能知道下一个随机数是什么。具体可参考随机数的故事

  • Crypto.getRandomValues()

  Crypto.getRandomValues() 方法让你可以获取符合密码学要求的安全的随机值。传入参数的数组被随机值填充(在加密意义上的随机)。window.crypto.getRandomValue的实现在 Safari,Chrome 和 Opera 浏览器上是使用带有 1024 位种子的ARC4流密码。

var array = new Uint32Array(10);
window.crypto.getRandomValues(array);

console.log('Your lucky numbers:');
for (var i = 0; i < array.length; i++) {
  console.log(array[i]);
}
参考资料:随机数的故事 | Crypto.getRandomValues() | 如何使用 window.crypto.getRandomValues 在 JavaScript 中调用扑克牌?

第五十一式:forEach 只是对 for 循环的简单封装?你理解的 forEach 可能并不正确

  我们先看看下面这个forEach的实现:

Array.prototype.forEachCustom = function (fn, context) {
  context = context || arguments[1];
  if (typeof fn !== 'function') {
    throw new TypeError(fn + 'is not a function');
  }

  for (let i = 0; i < this.length; i++) {
    fn.call(context, this[i], i, this);
  }
};

  我们发现,上面的代码实现其实只是对 for 循环的简单封装,看起来似乎没有什么问题,因为很多时候,forEach 方法是被用来代替 for 循环来完成数组遍历的。其实不然,我们再看看下面的测试代码:

//  示例1
const items = ['', 'item2', 'item3', , undefined, null, 0];
items.forEach((item) => {
  console.log(item); //  依次打印:'',item2,item3,undefined,null,0
});
items.forEachCustom((item) => {
  console.log(item); // 依次打印:'',item2,item3,undefined,undefined,null,0
});
// 示例2
let arr = new Array(8);
arr.forEach((item) => {
  console.log(item); //  无打印输出
});
arr[1] = 9;
arr[5] = 3;
arr.forEach((item) => {
  console.log(item); //  打印输出:9 3
});
arr.forEachCustom((item) => {
  console.log(item); // 打印输出:undefined 9 undefined*3  3 undefined*2
});

  我们发现,forEachCustom 和原生的 forEach 在上面测试代码的执行结果并不相同。关于各个新特性的实现,其实我们都可以在 ECMA 文档中找到答案:

forEach

  我们可以发现,真正执行遍历操作的是第 8 条,通过一个 while 循环来实现,循环的终止条件是前面获取到的数组的长度(也就是说后期改变数组长度不会影响遍历次数),while 循环里,会先把当前遍历项的下标转为字符串,通过 HasProperty 方法判断数组对象中是否有下标对应的已初始化的项,有的话,获取对应的值,执行回调,没有的话,不会执行回调函数,而是直接遍历下一项

  如此看来,forEach 不对未初始化的值进行任何操作(稀疏数组),所以才会出现示例 1 和示例 2 中自定义方法打印出的值和值的数量上均有差别的现象。那么,我们只需对前面的实现稍加改造,即可实现一个自己的 forEach 方法:

Array.prototype.forEachCustom = function (fn, context) {
  context = context || arguments[1];
  if (typeof fn !== 'function') {
    throw new TypeError(fn + 'is not a function');
  }

  let len = this.length;
  let k = 0;
  while (k < len) {
    // 下面是两种实现思路,ECMA文档使用的是HasProperty,在此,使用in应该比hasOwnProperty更确切
    // if (this.hasOwnProperty(k)) {
    //   fn.call(context, this[k], k, this);
    // };
    if (k in this) {
      fn.call(context, this[k], k, this);
    }
    k++;
  }
};

  再次运行示例 1 和示例 2 的测试用列,发现输出和原生 forEach 一致。

  通过文档,我们还发现,在迭代前 while 循环的次数就已经定了,且执行了 while 循环,不代表就一定会执行回调函数,我们尝试在迭代时修改数组:

// 示例3
var words = ['one', 'two', 'three', 'four'];
words.forEach(function (word) {
  console.log(word); // one,two,four(在迭代过程中删除元素,导致three被跳过,因为three的下标已经变成1,而下标为1的已经被遍历了过)
  if (word === 'two') {
    words.shift();
  }
});
words = ['one', 'two', 'three', 'four']; // 重新初始化数组进行forEachCustom测试
words.forEachCustom(function (word) {
  console.log(word); // one,two,four
  if (word === 'two') {
    words.shift();
  }
});
// 示例4
var arr = [1, 2, 3];
arr.forEach((item) => {
  if (item == 2) {
    arr.push(4);
    arr.push(5);
  }
  console.log(item); // 1,2,3(迭代过程中在末尾增加元素,并不会使迭代次数增加)
});
arr = [1, 2, 3];
arr.forEachCustom((item) => {
  if (item == 2) {
    arr.push(4);
    arr.push(5);
  }
  console.log(item); // 1,2,3
});

  以上过程启示我们,在工作中碰见和我们预期存在差异的问题时,我们完全可以去ECMA 官方文档中寻求答案。

这里可以参考笔者之前的一篇文章:JavaScript 很简单?那你理解的 forEach 真的对吗?

第五十二式:Git 文件名大小写敏感问题,你栽过坑吗?

  笔者大约两年前刚用 Mac 开发前端时曾经遇到一个坑:代码在本地运行 ok,但是发现 push 到 git,自动部署后报错了,排查了很久,最后发现有个文件名没有注意大小写,重命名了该文件,但是 git 没有识别到这个更改,导致自动部署后找不到这个文件。解决办法如下:

  • 查看 git 的设置:git config –get core.ignorecase
  • git 默认是不区分大小的,因此当你修改了文件名/文件夹的大小写后,git 并不会认为你有修改(git status 不会提示你有修改)
  • 更改设置解决:git config core.ignorecase false

  这么以来,git 就能识别到文件名大小写的更改了。在次建议,平时我们在使用 React 编写项目时,文件名最好保持首字母大写。

参考:在 Git 中当更改一个文件名为首字母大写时

第五十三式:你看到的0.1其实并不是真的0.1 —— 老生长谈的 0.1 + 0.2 !== 0.3,这次我们说点不一样的

  0.1 + 0.2 !== 0.3是一个老生长谈的问题来,想必你也明白其中的根源:JS 采用 IEEE 754 双精度版本(64 位),并且只要采用 IEEE 754 的语言都有这样的问题。详情可查看笔者之前的一篇文章0.1 + 0.2 != 0.3 背后的原理,本节我们只探讨解法。

  • 既然IEEE 754存在精度问题,那为什么 x=0.1 能得到 0.1

  因为在浮点数的存储中, mantissa(尾数) 固定长度是 52 位,再加上省略的一位,最多可以表示的数是 2^53=9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精度。它的长度是 16,所以可以使用 toPrecision(16) 来做精度运算,超过的精度会自动做凑整处理。于是便有:

0.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零后正好为 0.1

// 但你看到的 `0.1` 实际上并不是 `0.1`。不信你可用更高的精度试试:
0.1.toPrecision(21) = 0.100000000000000005551

toPrecision

  • toFixed设置精确位数

  toFixed() 方法可把 Number 四舍五入为指定小数位数的数字,语法:NumberObject.toFixed(num)

// 保留两位小数
console.log((0.1 + 0.2).toFixed(2)); // 0.30
  • Number.EPSILON

  想必你还有印象,在高中数学或者大学数学分析、数值逼近中,在证明两个值相等的时候,我们会让他们的差去逼近一个任意小的数。那么,在此自然可以想到让 0.1 + 0.2 的和减去 0.3 小于一个任意小的数,比如说我们可以通过他们差值是否小于 0.0000000001 来判断他们是否相等。

  其实 ES6 已经在 Number 对象上面,新增一个极小的常量 Number.EPSILON。根据规则,它表示 1 与大于 1 的最小浮点数之间的差。Number.EPSILON 实际上是 JavaScript 能够表示的最小精度。误差如果小于这个值,就可以认为已经没有意义了,即不存在误差了。

console.log(0.1 + 0.2 - 0.3 < Number.EPSILON); // true
  • 转换成整数或者字符串再进行求和运算

  为了避免产生精度差异,我们要把需要计算的数字乘以 10 的 n 次幂,换算成计算机能够精确识别的整数,然后再除以 10 的 n 次幂,大部分编程语言都是这样处理精度差异的,我们就借用过来处理一下 JS 中的浮点数精度误差。

传入 n 次幂的 n 值:

formatNum = function (f, digit) {
  var m = Math.pow(10, digit);
  return parseInt(f * m, 10) / m;
};
var num1 = 0.1;
var num2 = 0.2;
console.log(num1 + num2);
console.log(formatNum(num1 + num2, 1));

自动计算 n 次幂的 n 值:

/**
 * 精确加法
 */
function add(num1, num2) {
  const num1Digits = (num1.toString().split('.')[1] || '').length;
  const num2Digits = (num2.toString().split('.')[1] || '').length;
  const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
}
add(0.1,0.2); // 0.3
  • 使用类库:

  通常这种对精度要求高的计算都应该交给后端去计算和存储,因为后端有成熟的库来解决这种计算问题。前端也有几个不错的类库:

参考资料:JavaScript 浮点数运算的精度问题 | JavaScript 浮点数陷阱及解法

第五十四式:发版提醒全靠吼 —— 如何纯前端实现页面检测更新并提示?

  开发过程中,经常遇到页面更新、版本发布时,需要告诉使用人员刷新页面的情况,甚至有些运营、测试人员觉得切换一下菜单再切回去就是更新了 web 页面资源,有的分不清普通刷新和强刷的区别,所以实现了一个页面更新检测功能,页面更新了定时自动提示使用人员刷新页面。

  基本思路为:使用 webpack 配置打包编译时在 js 文件名里添加 hash,然后使用 js 向${window.location.origin}/index.html发送请求,解析出 html 文件里引入的 js 文件名称 hash,对比当前 js 的 hash 与新版本的 hash 是否一致,不一致则提示用户更新版本。

// uploadUtils.jsx
import React from 'react';
import axios from 'axios';
import { notification, Button } from 'antd';

// 弹窗是否已展示(可以改用闭包、单例模式等实现,看起来会更有逼格一点)
let uploadNotificationShow = false;

// 关闭notification
const close = () => {
  uploadNotificationShow = false;
};

// 刷新页面
const onRefresh = (new_hash) => {
  close();
  // 更新localStorage版本号信息
  window.localStorage.setItem('XXXSystemFrontVesion', new_hash);
  // 刷新页面
  window.location.reload(true);
};

// 展示提示弹窗
const openNotification = (new_hash) => {
  uploadNotificationShow = true;
  const btn = (
    <Button type='primary' size='small' onClick={() => onRefresh(new_hash)}>
      确认更新
    </Button>
  );
  // 这里不自动执行更新的原因是:
  // 考虑到也许此时用户正在使用系统甚至填写一个很长的表单,那你直接刷新了页面,或许会被掐死的,哈哈
  notification.open({
    message: '版本更新提示',
    description: '检测到系统当前版本已更新,请刷新后使用。',
    btn,
    // duration为0时,notification不自动关闭
    duration: 0,
    onClose: close,
  });
};

// 获取hash
export const getHash = () => {
  // 如果提示弹窗已展示,就没必要执行接下来的检查逻辑了
  if (!uploadNotificationShow) {
    // 在 js 中请求首页地址,这样不会刷新界面,也不会跨域
    axios
      .get(`${window.location.origin}/index.html?time=${new Date().getTime()}`)
      .then((res) => {
        // 匹配index.html文件中引入的js文件是否变化(具体正则,视打包时的设置及文件路径而定)
        let new_hash = res.data && res.data.match(/\/static\/js\/main.(.*).js/);
        // console.log(res, new_hash);
        new_hash = new_hash ? new_hash[1] : null;
        // 查看本地版本
        let old_hash = localStorage.getItem('XXXSystemFrontVesion');
        if (!old_hash) {
          // 如果本地没有版本信息(第一次使用系统),则直接执行一次额外的刷新逻辑
          onRefresh(new_hash);
        } else if (new_hash && new_hash != old_hash) {
          // 本地已有版本信息,但是和新版不同:需更新版本,弹出提示
          openNotification(new_hash);
        }
      });
  }
};

使用示例:

import { getHash } from './uploadUtils';

let timer = null;
componentDidMount() {
    getHash();
    timer = setInterval(() => {
      getHash();
      // 10分钟检测一次
    }, 600000)
  }

  componentWillUnmount () {
      // 页面卸载时记得清除
    clearInterval(timer);
  }

  结合Console Importer直接在控制台面板查看:

uploadpage

  你也完全可以在上面的方法上更上一层楼,build 的时候,在 index.html 同级目录下,自动生成一个 json 文件,包含新的文件的 hash 信息,检查版本的时候,就只需直接请求这个 json 文件进行对比了,减少冗余数据的传递。

参考资料:纯前端实现页面检测更新提示

本文首发于个人博客,欢迎指正和star

查看原文

独钓寒江雪 发布了文章 · 2020-12-21

前端装逼技巧 108 式(三)—— 冇得感情的API调用工程师

敲一夜代码,流两行老泪;用三种语言,唯四肢受罪;待五更鸡鸣,遇骤雨初歇;遂登门而去,伫十里长亭;欲望穿泪眼,无如意郎君;借微薄助力,愿寻得佳偶;成比翼双鸟,乃畅想云端;卷情网之内,做爬虫抓取;为连理桂枝,容数据分析;思千里子规,助框广天地;念茫茫人海,该如何寻觅?

系列文章发布汇总:

文章风格所限,引用资料部分,将在对应小节末尾标出。

第三十七式:茫然一顾眼前亮,懵懂宛如在梦中 —— "123​4".length === 5 ?这一刻,我感受到了眼睛的背叛和侮辱

  • 复制以下代码到浏览器控制台:
console.log('123​4'.length === 5); // true

12345

  哈哈,是不是有种被眼睛背叛的感觉?其实这就是所谓的零宽空格(Zero Width Space,简称“ZWSP”),零宽度字符是不可见的非打印字符,它用于打断长英文单词或长阿拉伯数字,以便于换行显示,否则长英文单词和长阿拉伯数字会越过盒模型的边界,常见于富文本编辑器,用于格式隔断。

  • 探究一下上面代码的玄机:
const common = '1234';
const special = '123​4';
console.log(common.length); // 4
console.log(special.length); // 5
console.log(encodeURIComponent(common)); // 1234
console.log(encodeURIComponent(special)); // 123%E2%80%8B4
// 把上面中间特殊字符部分进行解码
console.log(decodeURIComponent('%E2%80%8B')); // (空)

const otherSpecial = '123\u200b4'; // 或者"123\u{200b}4"
console.log(otherSpecial); // 1234
console.log(otherSpecial.length, common === special, special === otherSpecial); // 5 false true
  • 在 HTML 中使用零宽度空格(在 HTML 中,零宽度空格与<wbr>等效):
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <!-- &#8203; 和 <wbr /> 是零宽空格在html中的两种表示 -->
    <div>abc&#8203;def</div>
    <div>abc<wbr />def</div>
  </body>
</html>
ESLint 有一条禁止不规则的空白 (no-irregular-whitespace)的规则,防止代码里面误拷贝了一些诸如零宽空格类的空格,以免造成一些误导。
拓展:我们经常在 html 中使用的&nbsp;全称是No-Break SPace,即不间断空格,当 HTML 有多个连续的普通空格时,浏览器在渲染时只会渲染一个空格,而使用这个不间断空格,可以禁止浏览器合并空格。常用于富文本编辑器之中,当我们在富文本编辑器连续敲下多个空格时,最后输出的内容便会带有很多不间断空格。
参考资料:常见空格一览 - 李银城 | 什么是零宽度空格 | 维基百科-空格

第三十八式:如何禁止网页复制粘贴

  对于禁止网页复制粘贴,也许你并不陌生。一些网页是直接禁止复制粘贴;一些网页,则是要求登陆后才可复制粘贴;还有一些网站,复制粘贴时会带上网站的相关来源标识信息。

  • 如何禁止网页复制粘贴
const html = document.querySelector('html');
html.oncopy = () => {
  alert('牛逼你复制我呀');
  return false;
};
html.onpaste = () => false;
  • 在复制时做些别的操作,比如跳转登陆页面
const html = document.querySelector('html');
html.oncopy = (e) => {
  console.log(e);
  // 比如指向百度或者登陆页
  // window.location.href='http://www.baidu.com';
};
html.onpaste = (e) => {
  console.log(e);
};
  • 如何使用 js 设置/获取剪贴板内容
//设置剪切板内容
document.addEventListener('copy', () => {
  const clipboardData =
    event.clipboardData || event.originalEvent?.clipboardData;
  clipboardData?.setData('text/plain', '不管复制什么,都是我!');
  event.preventDefault();
});

//获取剪切板的内容
document.addEventListener('paste', () => {
  const clipboardData =
    event.clipboardData || event.originalEvent?.clipboardData;
  const text = clipboardData?.getData('text');
  console.log(text);
  event.preventDefault();
});
  • 有什么用

    • 对于注册输入密码等需要输入两次相同内容的场景,应该是需要禁止粘贴的,这时候就可以禁止对应输入框的复制粘贴动作。
    • 登陆才能复制。很多网站上的页面内容是不允许复制的,这样可以防止用户或者程序恶意的去抓取页面数据。
Tips:页面禁止复制,而你又想复制,怎么办:方法一,在浏览器设置 -> 隐私设置和安全性 -> 禁用JavaScript;方法二,审查元素,在Elements中找到对应DOM,进行复制。

参考资料:Clipboard API and events | Document.execCommand()

第三十九式:function.length指代什么? —— 认识柯里化和JS 函数重载

  在函数式编程里,有几个比较重要的概念:函数的合成、柯里化和函子。其中柯里化(Currying),是指把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。这个技术由 Christopher Strachey 以逻辑学家 Haskell Curry 命名的,但是它是 Moses Schnfinkel 和 Gottlob Frege 发明的。

  lodash 实现了_.curry函数,_.curry函数接收一个函数作为参数,返回新的柯里化(curry)函数。调用新的柯里化函数时,当传递的参数个数小于柯里化函数要求的参数时,返回一个接收剩余参数的函数,当传递的参数达到柯里化函数要求时,返回结果。那么,_.curry函数是如何判断传递的参数是否到达要求的呢?我们不妨先看看下面的例子:

function func(a, b, c) {
  console.log(func.length, arguments.length);
}
func(1); // 3  1
  • 看看 MDN 的解释:

    • length 是函数对象的一个属性值,指该函数有多少个必须要传入的参数,那些已定义了默认值的参数不算在内,比如 function(x = 0)的 length 是 0。即形参的数量仅包括第一个具有默认值之前的参数个数。
    • 与之对比的是, arguments.length 是函数被调用时实际传参的个数。
  • 实现 lodash curry 化函数
// 模拟实现 lodash 中的 curry 方法
function curry(func) {
  return function curriedFn(...args) {
    // 判断实参和形参的个数
    if (args.length < func.length) {
      return function () {
        return curriedFn(...args.concat(Array.from(arguments)));
      };
    }
    return func(...args);
  };
}

function getSum(a, b, c) {
  return a + b + c;
}

const curried = curry(getSum);

console.log(curried(1, 2, 3));
console.log(curried(1)(2, 3));
console.log(curried(1, 2)(3));
  • JS 函数重载

  函数重载,就是函数名称一样,但是允许有不同输入,根据输入的不同,调用不同的函数,返回不同的结果。JS 里默认是没有函数重载的,但是有了Function.length属性和arguments.length,我们便可简单的通过if…else或者switch来完成 JS 函数重载了。

function overLoading() {
  // 根据arguments.length,对不同的值进行不同的操作
  switch (arguments.length) {
    case 0 /*操作1的代码写在这里*/:
      break;
    case 1 /*操作2的代码写在这里*/:
      break;
    case 2: /*操作3的代码写在这里*/
  }
}

  更高级的函数重载,请参考 jQuery 之父 John Resig 的JavaScript Method Overloading, 这篇文章里,作者巧妙地利用闭包,实现了 JS 函数的重载。

参考资料:浅谈 JavaScript 函数重载 | JavaScript Method Overloading | 【译】JavaScript 函数重载 - Fundebug | Function.length | 函数式编程入门教程 - 阮一峰

第四十式:["1","7","11"].map(parseInt)为什么会返回[1,NaN,3]?

  • map 返回 3 个参数,item,index,Array,console.log可以接收任意个参数,所以[1,7,11].map(console.log)打印:

parseInt

  • parseInt 接受两个参数:string,radix,其中 radix 默认为 10;
  • 那么,每次调用 parseInt,相当于:parseInt(item,index,Array),map 传递的第三个参数 Array 会被忽略。index 为 0 时,parseInt(1,0),radix 取默认值 10;parseInt(7,1)中,7 在 1 进制中不存在,所以返回”NaN“;parseInt(11,2),2 进制中 11 刚好是十进制中的 3。
参考:JS 中为啥 ['1', '7', '11'].map(parseInt) 返回 [1, NaN, 3]

第四十一式:iframe 间数据传递,postMessage 可以是你的选择

  平时开发中,也许我们会遇到需要在非同源站点、iframe 间传递数据的情况,这个时候,我们可以使用 postMessage 完成数据的传递。
  window.postMessage() 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的模数 Document.domain 设置为相同的值) 时,这两个脚本才能相互通信(即同源)。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

// 页面1 触发事件,发送数据
top.postMessage(data, '*');
// window  当前所在iframe
// parent  上一层iframe
// top     最外层iframe

//页面2 监听message事件
useEffect(() => {
  const listener = (ev) => {
    console.log(ev, ev.data);
  };
  window.addEventListener('message', listener);
  return () => {
    window.removeEventListener('message', listener);
  };
}, []);

注意:

  • postMessage第二个参数 targetOrigin 用来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。
  • 如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的 targetOrigin,而不是"*"。
  • 不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。
参考资料:window.postMessage

第四十二式:薛定谔的 X —— 有趣的let x = x

  薛定谔的猫(英文名称:Erwin Schrödinger's Cat)是奥地利著名物理学家薛定谔提出的一个思想实验,是指将一只猫关在装有少量镭和氰化物的密闭容器里。镭的衰变存在几率,如果镭发生衰变,会触发机关打碎装有氰化物的瓶子,猫就会死;如果镭不发生衰变,猫就存活。根据量子力学理论,由于放射性的镭处于衰变和没有衰变两种状态的叠加,猫就理应处于死猫和活猫的叠加状态。这只既死又活的猫就是所谓的“薛定谔猫”。

  JS 引入 let 和 const 之后,也出现了一种有趣的现象:

<!-- 可以拷贝下面的代码,放的一个html文件中,然后使用浏览器打开,查看控制台 -->
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      let x = x;
    </script>
    <script>
      x = 2;
      console.log(x);
    </script>
  </body>
</html>

specx

  上面的代码里,我们在第一个 script 里引入写了let x = x;,就导致在其他 script 下都无法在全局作用域下使用 x 变量了(无论是对 x 进行赋值、取值,还是声明,都不行)。也就是说现在 x 处于一种“既被定义了,又没被定义”的中间状态。

  这个问题说明:如果 let x 的初始化过程失败了,那么:

  • x 变量就将永远处于 created 状态。
  • 你无法再次对 x 进行初始化(初始化只有一次机会,而那次机会你失败了)。
  • 由于 x 无法被初始化,所以 x 永远处在暂时死区(也就是盗梦空间里的 limbo)!
  • 有人会觉得 JS 坑,怎么能出现这种情况;其实问题不大,因为此时代码已经报错了,后面的代码想执行也没机会。
参考资料:JS 变量封禁大法:薛定谔的 X

第四十三式:聊聊前端错误处理

一个 React-dnd 引出的前端错误处理

  年初的时候,笔者曾做过一个前端错误处理的笔记,事情是这样的:

  项目中某菜单定义的页面因有拖拽的需求,就引入了React DnD来完成这一工作;随着业务的更新迭代,部分列表页面又引入了自定义列的功能,可以通过拖动来对列进行排序,后面就发现在某些页面上,试图打开自定义列的弹窗时,页面就崩溃白屏了,控制台会透出错误:'Cannot have two HTML5 backends at the same time.'。在排查问题的时候,查看源码发现:

// ...
value: function setup() {
  if (this.window === undefined) {
    return;
  }
  if (this.window.__isReactDndBackendSetUp) {
    throw new Error('Cannot have two HTML5 backends at the same time.');
  }
  this.window.__isReactDndBackendSetUp = true;
  this.addEventListeners(this.window);
}
// ...

  也就是说,react-dnd-html5-backend在创建新的实例前会通过window.__isReactDndBackendSetUp的全局变量来判断是否已经存在一个可拖拽组件,如果有的话,就直接报错,而由于项目里对应组件没有相应的错误处理逻辑,抛出的 Error 异常层层上传到 root,一直没有被捕获和处理,最终导致页面崩溃。其实在当时的业务场景下,这个问题比较好解决,因为菜单定义页面没有自定义列的需求,而其他页面自定义列又是通过弹窗展示的,所以不要忘了给自定义列弹窗设置 destroyOnClose 属性(关闭销毁)即可。为了避免项目中因为一些错误导致系统白屏,在项目中,我们应该合理使用错误处理。

前端错误处理的方法

1、Error Boundaries

  如何使一个 React 组件变成一个“Error Boundaries”呢?只需要在组件中定义个新的生命周期函数——componentDidCatch(error, info):

error: 这是一个已经被抛出的错误;info:这是一个 componentStack key。这个属性有关于抛出错误的组件堆栈信息。
// ErrorBoundary实现
class ErrorBoundary extends React.Component {
  state = { hasError: false };

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

ErrorBoundary 使用:

// ErrorBoundary使用
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>
Erro Boundaries 本质上也是一个组件,通过增加了新的生命周期函数 componentDidCatch 使其变成了一个新的组件,这个特殊组件可以捕获其子组件树中的 js 错误信息,输出错误信息或者在报错条件下,显示默认错误页。注意一个 Error Boundaries 只能捕获其子组件中的 js 错误,而不能捕获其组件本身的错误和非子组件中的 js 错误。

  但是 Error Boundaries 也不是万能的,下面我们来看哪些情况下不能通过 Error Boundaries 来 catch{}错误:

  • 组件内部的事件处理函数,因为 Error Boundaries 处理的仅仅是 Render 中的错误,而 Hander Event 并不发生在 Render 过程中。
  • 异步函数中的异常 Error Boundaries 不能 catch,比如 setTimeout 或者 setInterval 、requestAnimationFrame 等函数中的异常。
  • 服务器端的 rendering
  • 发生在 Error Boundaries 组件本身的错误

2、componentDidCatch()生命周期函数:

  componentDidCatch 是一个新的生命周期函数,当组件有了这个生命周期函数,就成为了一个 Error Boundaries。

3、try/catch 模块

  Error Boundaries 仅仅抛出了子组件的错误信息,并且不能抛出组件中的事件处理函数中的异常。(因为 Error Boundaries 仅仅能保证正确的 render,而事件处理函数并不会发生在 render 过程中),我们需要用 try/catch 来处理事件处理函数中的异常。

try/catch 只能捕获到同步的运行时错误,对语法和异步错误却无能为力。

4、window.onerror

  当 JS 运行时错误发生时,window 会触发一个 ErrorEvent 接口的 error 事件,并执行 window.onerror()。

在实际使用过程中,onerror 主要是来捕获预料之外的错误,而 try-catch 则是用来在可预见情况下监控特定的错误,两者结合使用更加高效。
/**
 * @param {String}  message    错误信息
 * @param {String}  source    出错文件
 * @param {Number}  lineno    行号
 * @param {Number}  colno    列号
 * @param {Object}  error  Error对象(对象)
 */
window.onerror = function (message, source, lineno, colno, error) {
  console.log('捕获到异常:', { message, source, lineno, colno, error });
  // window.onerror 函数只有在返回 true 的时候,异常才不会向上抛出,否则即使是知道异常的发生控制台还是会显示 Uncaught Error: xxxxx。
  //  return true;
};

5、window.addEventListener

  主要用于静态资源加载异常捕获。

6、Promise Catch

  try..catch..虽然能捕获错误,但是不能捕获异步的异常;promise碰到then,也就是resolve或者reject的时候是异步的,所以try...catch对它是没有用的。Promise.prototype.catch 方法是用于指定发生错误时的回调函数。

7、unhandledrejection

  当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在 window 下,但也可能发生在 Worker 中。 unhandledrejection 继承自 PromiseRejectionEvent,而 PromiseRejectionEvent 又继承自 Event。因此 unhandledrejection 含有 PromiseRejectionEvent 和 Event 的属性和方法。

总结

  前端组件/项目中,需要有适当的错误处理过程,否则出现错误,层层上传,没有进行捕获,就会导致页面挂掉。

第四十四式:不做工具人 —— 使用 nodejs 根据配置自动生成文件

  笔者在工作中有一个需求是搭建一个 BFF 层项目,实现对每一个接口的权限控制和转发到后端底层接口。因为 BFF 层接口逻辑较少,70%情况下都只是实现一个转发,所以每个文件相似度较高,但因为每个 API 需单独控制权限,所以 API 文件又必须存在,所以使用 nodejs 编写 API 自动化生成脚本,避免进行大量的手动创建文件和复制修改的操作,示例如下:

  • 编写自动生成文件的脚本:
// auto.js
const fs = require('fs');
const path = require('path');
const config = require('./apiConfig'); // json配置文件,格式见下面注释内容
// config的格式如下:
// [
//     {
//         filename: 'querySupplierInfoForPage.js',
//         url: '/supplier/rest/v1/supplier/querySupplierInfoForPage',
//         comment: '分页查询供应商档案-主信息',
//     },
// ]

// 验证数量是否一致
// 也可以在此做一些其他的验证,需要验证时调用这个函数即可
function verify() {
  console.log(
    config.length,
    fs.readdirSync(path.join(__dirname, '/server/api')).length
  );
}

// 生成文件
function writeFileAuto(filePath, item) {
  fs.writeFileSync(
    filePath,
    `/**
* ${item.comment}
*/
const { Controller, Joi } = require('ukoa');

module.exports = class ${item.filename.split('.')[0]} extends Controller {
    init() {
        this.schema = {
            Params: Joi.object().default({}).notes('参数'),
            Action: Joi.string().required().notes('Action')
        };
    }

    // 执行函数体
    async main() {
        const { http_supply_chain } = this.ctx.galaxy;
        const [data] = await http_supply_chain("${
          item.url
        }", this.params.Params, { throw: true });
        return this.ok = data.obj;
    }
};
`
  );
}

function exec() {
  config.forEach((item) => {
    var filePath = path.join(__dirname, '/server/api/', item.filename);
    fs.exists(filePath, function (exists) {
      if (exists) {
        // 已存在的文件就不要重复生成了,因为也许你已经对已存在的文件做了特殊逻辑处理
        //(毕竟只有70%左右的API是纯转发,还有30%左右有自己的处理逻辑)
        console.log(`文件${item.filename}已存在`);
      } else {
        console.log(`创建文件:${item.filename}`);
        writeFileAuto(filePath, item);
      }
    });
  });
}

exec();
  • 执行脚本,生成文件如下:node auto.js
// querySupplierInfoForPage.js
/**
 * 分页查询供应商档案-主信息
 */
const { Controller, Joi } = require('ukoa');

module.exports = class querySupplierInfoForPage extends (
  Controller
) {
  init() {
    this.schema = {
      Params: Joi.object().default({}).notes('参数'),
      Action: Joi.string().required().notes('Action'),
    };
  }

  // 执行函数体
  async main() {
    const { http_supply_chain } = this.ctx.galaxy;
    const [
      data,
    ] = await http_supply_chain(
      '/supplier/rest/v1/supplier/querySupplierInfoForPage',
      this.params.Params,
      { throw: true }
    );
    return (this.ok = data.obj);
  }
};

  此处只是抛砖引玉,结合具体业务场景,也许你会为 nodejs 脚本找到更多更好的用法,为前端赋能。

第四十五式:明明元素存在,我的document.getElementsByTagName('video')却获取不到?

  • 使用 Chrome 浏览器在线看视频的时候,有些网站不支持倍速播放;有的网站只支持 1.5 和 2 倍速,但是自己更喜欢 1.75 倍;又或者有些网站需要会员才能倍速播放(比如某盘),一般我们可以通过安装相应的浏览器插件解决,如果不愿意安装插件,也可以使用类似document.getElementsByTagName('video')[0].playbackRate = 1.75(1.75 倍速)的方式实现倍速播放,这个方法在大部分网站上是有效的(当然,如果知道 video 标签的 id 或者 class,通过 id 和 class 来获取元素会更便捷一点),经测试,playbackRate支持的最大倍速 Chrome 下是 16。同时,给playbackRate设置一个小于 1 的值,比如 0.3,可以模拟出类似鬼片的音效
  • 但是在某盘,这种方法却失效了,因为我没有办法获取到 video 元素,审查元素如下:
    videojs

  审查元素时,我们发现了#shadow-root (closed)videojs的存在。也许你还记得,在第六式中我们曾简单探讨过Web Components,其中介绍到attachShadow()方法可以开启 Shadow DOM(这部分 DOM 默认与外部 DOM 隔离,内部任何代码都无法影响外部,避免样式等的相互干扰),隐藏自定义元素的内部实现,我们外部也没法获取到相应元素,如下图所以(点击图片跳转 Web Components 示例代码):

shadow

  是以,我们可以合理推断,某盘的网页视频播放也使用了类似Element.attachShadow()方法进行了元素隐藏,所以我们无法通过document.getElementsByTagName('video')获取到 video 元素。通过阅读videojs 文档发现,可以通过相应 API 实现自定义倍速播放:

videojs.getPlayers('video-player').html5player.tech_.setPlaybackRate(1.666);
参考资料:百度网盘视频倍速播放方法 | videojs 文档 | Element.attachShadow() | 深入理解 Shadow DOM v1

第四十六式:SQL 也可以 if else? —— 不常写 SQL 的我神奇的知识增加了

  在刷 leetcode 的时候遇到一个 SQL 题目627. 变更性别,题目要求如下:

给定一个  salary  表,有 m = 男性 和 f = 女性 的值。交换所有的 f 和 m 值(例如,将所有 f 值更改为 m,反之亦然)。要求只使用一个更新(Update)语句,并且没有中间的临时表。注意,您必只能写一个 Update 语句,请不要编写任何 Select 语句。
  UPDATE salary
    SET
      sex = CASE sex
          WHEN 'm' THEN 'f'
          ELSE 'm'
        END;
参考资料:SQL 之 CASE WHEN 用法详解

第四十七式:庭院深深深几许,杨柳堆烟,帘幕无重数 —— 如何实现深拷贝?

  深拷贝,在前端面试里似乎是一个永恒的话题了,最简单的方法是JSON.stringify()以及JSON.parse(),但是这种方法能正确处理的对象只有 Number, String, Boolean, Array, 扁平对象,不可以拷贝 undefined , function, RegExp 等类型。还有其他一些包括扩展运算符、object.asign、递归拷贝、lodash 库等的实现,网上有很多相关资料和实现,这里不是我们讨论的重点。这次我们来探讨一个新的实现 —— MessageChannel。我们直接看代码:

// 创建一个obj对象,这个对象中有 undefined 和 循环引用
let obj = {
  a: 1,
  b: {
    c: 2,
    d: 3,
  },
  f: undefined,
};
obj.c = obj.b;
obj.e = obj.a;
obj.b.c = obj.c;
obj.b.d = obj.b;
obj.b.e = obj.b.c;

// 深拷贝方法封装
function deepCopy(obj) {
  return new Promise((resolve) => {
    const { port1, port2 } = new MessageChannel();
    port1.postMessage(obj);
    port2.onmessage = (e) => resolve(e.data);
  });
}

// 调用
deepCopy(obj).then((copy) => {
  // 请记住`MessageChannel`是异步的这个前提!
  let copyObj = copy;
  console.log(copyObj, obj);
  console.log(copyObj == obj);
});

  我们发现MessageChannelpostMessage传递的数据也是深拷贝的,这和web workerpostMessage一样。而且还可以拷贝 undefined 和循环引用的对象。简单说,MessageChannel创建了一个通信的管道,这个管道有两个端口,每个端口都可以通过postMessage发送数据,而一个端口只要绑定了onmessage回调方法,就可以接收从另一个端口传过来的数据。

需要说明的一点是:MessageChannel在拷贝有函数的对象时,还是会报错。

参考资料:MessageChannel | MessageChannel 是什么,怎么使用?

第四十八式:换了电脑,如何使用 VSCode 保存插件配置?

  也许每一个冇得感情的 API 调用工程师在使用 VSCode 进行开发时,都有自己的插件、个性化配置以及代码片段等,使用 VSCode 不用登陆,不用注册账号,确实很方便,但这同时也带来一个问题:如果你有多台电脑,比如家里一个、公司一个,都会用来开发;又或者,你离职入职了新的公司。此时,我们就需要从头再次配置一遍 VSCode,包括插件、配置、代码片段,如此反复,也许真的会崩溃。其实 VSCode 提供了 setting sync 插件,来方便我们同步插件配置。具体使用如下:

  • 在 VSCode 中搜索 Settings Sync 并进行安装;
  • 安装后,摁下 Ctrl(mac 为 command)+ Shift + P 打开控制面板,搜索 Sync,选择 Sync: Update/Upload Settings 可以上传你的配置,选择 Sync: Download Settings 会下载远程配置;
  • 如果你之前没有使用过 Settings Sync,在上传配置的时候,会让你在 Github 上创建一个授权码,允许 IDE 在你的 gist 中创建资源;下载远程配置,你可以直接将 gist 的 id 填入。
  • 下载后等待安装,然后重启即可。

  如此以来,我们就可以在多台设备间同步配置了。

参考资料:Settings Sync | VSCode 保存插件配置并使用 gist 管理代码片段

第四十九式:防止对象被篡改,可以试试 Object.seal 和 Object.freeze

  有时候你可能怕你的对象被误改了,所以需要把它保护起来。

  • Object.seal 防止新增和删除属性

  通常,一个对象是可扩展的(可以添加新的属性)。使用Object.seal()方法封闭一个对象会让这个对象变的不能添加新属性,且所有已有属性会变的不可配置。属性不可配置的效果就是属性变的不可删除,以及一个数据属性不能被重新定义成为访问器属性,或者反之。当前属性的值只要原来是可写的就可以改变。尝试删除一个密封对象的属性或者将某个密封对象的属性从数据属性转换成访问器属性,结果会静默失败或抛出 TypeError。

数据属性包含一个数据值的位置,在这个位置可以读取和写入值。访问器属性不包含数据值,它包含一对 getter 和 setter 函数。当读取访问器属性时,会调用 getter 函数并返回有效值;当写入访问器属性时,会调用 setter 函数并传入新值,setter 函数负责处理数据。
const person = {
  name: 'jack',
};
Object.seal(person);
delete person.name;
console.log(person); // {name: "jack"}
  • Object.freeze 冻结对象

  Object.freeze() 方法可以冻结一个对象。一个被冻结的对象再也不能被修改;冻结了一个对象则不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值。此外,冻结一个对象后该对象的原型也不能被修改。freeze() 返回和传入的参数相同的对象。

const obj = {
  prop: 42,
};
Object.freeze(obj);
obj.prop = 33;
// Throws an error in strict mode
console.log(obj.prop);
// expected output: 42
Tips:Object.freeze浅冻结,即只冻结一层,要使对象不可变,需要递归冻结每个类型为对象的属性(深冻结)。使用Object.freeze()冻结的对象中的现有属性值是不可变的。用Object.seal()密封的对象可以改变其现有属性值。同时可以使用 Object.isFrozenObject.isSealedObject.isExtensible 判断当前对象的状态。
  • Object.defineProperty 冻结单个属性:设置 enumable/writable 为 false,那么这个属性将不可遍历和写。
参考资料:JS 高级技巧 | javascript 的数据属性和访问器属性 | Object.freeze() | Object.seal() | 深入浅出 Object.defineProperty()

第五十式:不随机的随机数 —— 我们都知道Math.random是伪随机的,那如何得到密码学安全的随机数

  在 JavaScript 中产生随机数的方式是调用 Math.random,这个函数返回[0, 1)之间的数字,我们通过对Math.random的包装处理,可以得到我们想要的各种随机值。

  • 怎么实现一个随机数发生器
// from stackoverflow
// 下面的实现还是很随机的
let seed = 1;
function random() {
  let x = Math.sin(seed++) * 10000;
  return x - Math.floor(x);
}

  随机数发生器函数需要一个种子 seed,每次调用 random 函数的时候种子都会发生变化。因为random()是一个没有输入的函数,不管执行多少次,其运行结果都是一样的,所以需要有一个不断变化的入参,这个入参就叫种子,每运行一次种子就会发生一次变化。所以我们可以借助以上思路实现自己的随机数发生器(或许有些场合,我们不必管他是不是真的是随机的,再或者就是要让他不随机呢)。

  • 为什么说 Math.random 是不安全的呢?

  V8 源码显示 Math.random 种子的可能个数为 2 ^ 64, 随机算法相对简单,只是保证尽可能的随机分布。我们知道扑克牌有 52 张,总共有 52! = 2 ^ 226 种组合,如果随机种子只有 2 ^ 64 种可能,那么可能会有大量的组合无法出现。

  从 V8 里 Math.random 的实现逻辑来看,每次会一次性产生 128 个随机数,并放到 cache 里面,供后续使用,当 128 个使用完了再重新生成一批随机数。所以 Math.random 的随机数具有可预测性,这种由算法生成的随机数也叫伪随机数。只要种子确定,随机算法也确定,便能知道下一个随机数是什么。具体可参考随机数的故事

  • Crypto.getRandomValues()

  Crypto.getRandomValues() 方法让你可以获取符合密码学要求的安全的随机值。传入参数的数组被随机值填充(在加密意义上的随机)。window.crypto.getRandomValue的实现在 Safari,Chrome 和 Opera 浏览器上是使用带有 1024 位种子的ARC4流密码。

var array = new Uint32Array(10);
window.crypto.getRandomValues(array);

console.log('Your lucky numbers:');
for (var i = 0; i < array.length; i++) {
  console.log(array[i]);
}
参考资料:随机数的故事 | Crypto.getRandomValues() | 如何使用 window.crypto.getRandomValues 在 JavaScript 中调用扑克牌?

第五十一式:forEach 只是对 for 循环的简单封装?你理解的 forEach 可能并不正确

  我们先看看下面这个forEach的实现:

Array.prototype.forEachCustom = function (fn, context) {
  context = context || arguments[1];
  if (typeof fn !== 'function') {
    throw new TypeError(fn + 'is not a function');
  }

  for (let i = 0; i < this.length; i++) {
    fn.call(context, this[i], i, this);
  }
};

  我们发现,上面的代码实现其实只是对 for 循环的简单封装,看起来似乎没有什么问题,因为很多时候,forEach 方法是被用来代替 for 循环来完成数组遍历的。其实不然,我们再看看下面的测试代码:

//  示例1
const items = ['', 'item2', 'item3', , undefined, null, 0];
items.forEach((item) => {
  console.log(item); //  依次打印:'',item2,item3,undefined,null,0
});
items.forEachCustom((item) => {
  console.log(item); // 依次打印:'',item2,item3,undefined,undefined,null,0
});
// 示例2
let arr = new Array(8);
arr.forEach((item) => {
  console.log(item); //  无打印输出
});
arr[1] = 9;
arr[5] = 3;
arr.forEach((item) => {
  console.log(item); //  打印输出:9 3
});
arr.forEachCustom((item) => {
  console.log(item); // 打印输出:undefined 9 undefined*3  3 undefined*2
});

  我们发现,forEachCustom 和原生的 forEach 在上面测试代码的执行结果并不相同。关于各个新特性的实现,其实我们都可以在 ECMA 文档中找到答案:

forEach

  我们可以发现,真正执行遍历操作的是第 8 条,通过一个 while 循环来实现,循环的终止条件是前面获取到的数组的长度(也就是说后期改变数组长度不会影响遍历次数),while 循环里,会先把当前遍历项的下标转为字符串,通过 HasProperty 方法判断数组对象中是否有下标对应的已初始化的项,有的话,获取对应的值,执行回调,没有的话,不会执行回调函数,而是直接遍历下一项

  如此看来,forEach 不对未初始化的值进行任何操作(稀疏数组),所以才会出现示例 1 和示例 2 中自定义方法打印出的值和值的数量上均有差别的现象。那么,我们只需对前面的实现稍加改造,即可实现一个自己的 forEach 方法:

Array.prototype.forEachCustom = function (fn, context) {
  context = context || arguments[1];
  if (typeof fn !== 'function') {
    throw new TypeError(fn + 'is not a function');
  }

  let len = this.length;
  let k = 0;
  while (k < len) {
    // 下面是两种实现思路,ECMA文档使用的是HasProperty,在此,使用in应该比hasOwnProperty更确切
    // if (this.hasOwnProperty(k)) {
    //   fn.call(context, this[k], k, this);
    // };
    if (k in this) {
      fn.call(context, this[k], k, this);
    }
    k++;
  }
};

  再次运行示例 1 和示例 2 的测试用列,发现输出和原生 forEach 一致。

  通过文档,我们还发现,在迭代前 while 循环的次数就已经定了,且执行了 while 循环,不代表就一定会执行回调函数,我们尝试在迭代时修改数组:

// 示例3
var words = ['one', 'two', 'three', 'four'];
words.forEach(function (word) {
  console.log(word); // one,two,four(在迭代过程中删除元素,导致three被跳过,因为three的下标已经变成1,而下标为1的已经被遍历了过)
  if (word === 'two') {
    words.shift();
  }
});
words = ['one', 'two', 'three', 'four']; // 重新初始化数组进行forEachCustom测试
words.forEachCustom(function (word) {
  console.log(word); // one,two,four
  if (word === 'two') {
    words.shift();
  }
});
// 示例4
var arr = [1, 2, 3];
arr.forEach((item) => {
  if (item == 2) {
    arr.push(4);
    arr.push(5);
  }
  console.log(item); // 1,2,3(迭代过程中在末尾增加元素,并不会使迭代次数增加)
});
arr = [1, 2, 3];
arr.forEachCustom((item) => {
  if (item == 2) {
    arr.push(4);
    arr.push(5);
  }
  console.log(item); // 1,2,3
});

  以上过程启示我们,在工作中碰见和我们预期存在差异的问题时,我们完全可以去ECMA 官方文档中寻求答案。

这里可以参考笔者之前的一篇文章:JavaScript 很简单?那你理解的 forEach 真的对吗?

第五十二式:Git 文件名大小写敏感问题,你栽过坑吗?

  笔者大约两年前刚用 Mac 开发前端时曾经遇到一个坑:代码在本地运行 ok,但是发现 push 到 git,自动部署后报错了,排查了很久,最后发现有个文件名没有注意大小写,重命名了该文件,但是 git 没有识别到这个更改,导致自动部署后找不到这个文件。解决办法如下:

  • 查看 git 的设置:git config –get core.ignorecase
  • git 默认是不区分大小的,因此当你修改了文件名/文件夹的大小写后,git 并不会认为你有修改(git status 不会提示你有修改)
  • 更改设置解决:git config core.ignorecase false

  这么以来,git 就能识别到文件名大小写的更改了。在次建议,平时我们在使用 React 编写项目时,文件名最好保持首字母大写。

参考:在 Git 中当更改一个文件名为首字母大写时

第五十三式:你看到的0.1其实并不是真的0.1 —— 老生长谈的 0.1 + 0.2 !== 0.3,这次我们说点不一样的

  0.1 + 0.2 !== 0.3是一个老生长谈的问题来,想必你也明白其中的根源:JS 采用 IEEE 754 双精度版本(64 位),并且只要采用 IEEE 754 的语言都有这样的问题。详情可查看笔者之前的一篇文章0.1 + 0.2 != 0.3 背后的原理,本节我们只探讨解法。

  • 既然IEEE 754存在精度问题,那为什么 x=0.1 能得到 0.1

  因为在浮点数的存储中, mantissa(尾数) 固定长度是 52 位,再加上省略的一位,最多可以表示的数是 2^53=9007199254740992,对应科学计数尾数是 9.007199254740992,这也是 JS 最多能表示的精度。它的长度是 16,所以可以使用 toPrecision(16) 来做精度运算,超过的精度会自动做凑整处理。于是便有:

0.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零后正好为 0.1

// 但你看到的 `0.1` 实际上并不是 `0.1`。不信你可用更高的精度试试:
0.1.toPrecision(21) = 0.100000000000000005551

toPrecision

  • toFixed设置精确位数

  toFixed() 方法可把 Number 四舍五入为指定小数位数的数字,语法:NumberObject.toFixed(num)

// 保留两位小数
console.log((0.1 + 0.2).toFixed(2)); // 0.30
  • Number.EPSILON

  想必你还有印象,在高中数学或者大学数学分析、数值逼近中,在证明两个值相等的时候,我们会让他们的差去逼近一个任意小的数。那么,在此自然可以想到让 0.1 + 0.2 的和减去 0.3 小于一个任意小的数,比如说我们可以通过他们差值是否小于 0.0000000001 来判断他们是否相等。

  其实 ES6 已经在 Number 对象上面,新增一个极小的常量 Number.EPSILON。根据规则,它表示 1 与大于 1 的最小浮点数之间的差。Number.EPSILON 实际上是 JavaScript 能够表示的最小精度。误差如果小于这个值,就可以认为已经没有意义了,即不存在误差了。

console.log(0.1 + 0.2 - 0.3 < Number.EPSILON); // true
  • 转换成整数或者字符串再进行求和运算

  为了避免产生精度差异,我们要把需要计算的数字乘以 10 的 n 次幂,换算成计算机能够精确识别的整数,然后再除以 10 的 n 次幂,大部分编程语言都是这样处理精度差异的,我们就借用过来处理一下 JS 中的浮点数精度误差。

传入 n 次幂的 n 值:

formatNum = function (f, digit) {
  var m = Math.pow(10, digit);
  return parseInt(f * m, 10) / m;
};
var num1 = 0.1;
var num2 = 0.2;
console.log(num1 + num2);
console.log(formatNum(num1 + num2, 1));

自动计算 n 次幂的 n 值:

/**
 * 精确加法
 */
function add(num1, num2) {
  const num1Digits = (num1.toString().split('.')[1] || '').length;
  const num2Digits = (num2.toString().split('.')[1] || '').length;
  const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
}
add(0.1,0.2); // 0.3
  • 使用类库:

  通常这种对精度要求高的计算都应该交给后端去计算和存储,因为后端有成熟的库来解决这种计算问题。前端也有几个不错的类库:

参考资料:JavaScript 浮点数运算的精度问题 | JavaScript 浮点数陷阱及解法

第五十四式:发版提醒全靠吼 —— 如何纯前端实现页面检测更新并提示?

  开发过程中,经常遇到页面更新、版本发布时,需要告诉使用人员刷新页面的情况,甚至有些运营、测试人员觉得切换一下菜单再切回去就是更新了 web 页面资源,有的分不清普通刷新和强刷的区别,所以实现了一个页面更新检测功能,页面更新了定时自动提示使用人员刷新页面。

  基本思路为:使用 webpack 配置打包编译时在 js 文件名里添加 hash,然后使用 js 向${window.location.origin}/index.html发送请求,解析出 html 文件里引入的 js 文件名称 hash,对比当前 js 的 hash 与新版本的 hash 是否一致,不一致则提示用户更新版本。

// uploadUtils.jsx
import React from 'react';
import axios from 'axios';
import { notification, Button } from 'antd';

// 弹窗是否已展示(可以改用闭包、单例模式等实现,看起来会更有逼格一点)
let uploadNotificationShow = false;

// 关闭notification
const close = () => {
  uploadNotificationShow = false;
};

// 刷新页面
const onRefresh = (new_hash) => {
  close();
  // 更新localStorage版本号信息
  window.localStorage.setItem('XXXSystemFrontVesion', new_hash);
  // 刷新页面
  window.location.reload(true);
};

// 展示提示弹窗
const openNotification = (new_hash) => {
  uploadNotificationShow = true;
  const btn = (
    <Button type='primary' size='small' onClick={() => onRefresh(new_hash)}>
      确认更新
    </Button>
  );
  // 这里不自动执行更新的原因是:
  // 考虑到也许此时用户正在使用系统甚至填写一个很长的表单,那你直接刷新了页面,或许会被掐死的,哈哈
  notification.open({
    message: '版本更新提示',
    description: '检测到系统当前版本已更新,请刷新后使用。',
    btn,
    // duration为0时,notification不自动关闭
    duration: 0,
    onClose: close,
  });
};

// 获取hash
export const getHash = () => {
  // 如果提示弹窗已展示,就没必要执行接下来的检查逻辑了
  if (!uploadNotificationShow) {
    // 在 js 中请求首页地址,这样不会刷新界面,也不会跨域
    axios
      .get(`${window.location.origin}/index.html?time=${new Date().getTime()}`)
      .then((res) => {
        // 匹配index.html文件中引入的js文件是否变化(具体正则,视打包时的设置及文件路径而定)
        let new_hash = res.data && res.data.match(/\/static\/js\/main.(.*).js/);
        // console.log(res, new_hash);
        new_hash = new_hash ? new_hash[1] : null;
        // 查看本地版本
        let old_hash = localStorage.getItem('XXXSystemFrontVesion');
        if (!old_hash) {
          // 如果本地没有版本信息(第一次使用系统),则直接执行一次额外的刷新逻辑
          onRefresh(new_hash);
        } else if (new_hash && new_hash != old_hash) {
          // 本地已有版本信息,但是和新版不同:需更新版本,弹出提示
          openNotification(new_hash);
        }
      });
  }
};

使用示例:

import { getHash } from './uploadUtils';

let timer = null;
componentDidMount() {
    getHash();
    timer = setInterval(() => {
      getHash();
      // 10分钟检测一次
    }, 600000)
  }

  componentWillUnmount () {
      // 页面卸载时记得清除
    clearInterval(timer);
  }

  结合Console Importer直接在控制台面板查看:

uploadpage

  你也完全可以在上面的方法上更上一层楼,build 的时候,在 index.html 同级目录下,自动生成一个 json 文件,包含新的文件的 hash 信息,检查版本的时候,就只需直接请求这个 json 文件进行对比了,减少冗余数据的传递。

参考资料:纯前端实现页面检测更新提示

本文首发于个人博客,欢迎指正和star

查看原文

赞 43 收藏 31 评论 0

独钓寒江雪 赞了文章 · 2020-12-12

实施微前端的六种方式

微前端架构是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用

由此带来的变化是,这些前端应用可以独立运行独立开发独立部署。以及,它们应该可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 来管理。

注意:这里的前端应用指的是前后端分离的单应用页面,在这基础才谈论微前端才有意义。

结合我最近半年在微前端方面的实践和研究来看,微前端架构一般可以由以下几种方式进行:

  1. 使用 HTTP 服务器的路由来重定向多个应用
  2. 在不同的框架之上设计通讯、加载机制,诸如 MooaSingle-SPA
  3. 通过组合多个独立应用、组件来构建一个单体应用
  4. iFrame。使用 iFrame 及自定义消息传递机制
  5. 使用纯 Web Components 构建应用
  6. 结合 Web Components 构建

不同的方式适用于不同的使用场景,当然也可以组合一起使用。那么,就让我们来一一了解一下,为以后的架构演进做一些技术铺垫。

基础铺垫:应用分发路由 -> 路由分发应用

在一个单体前端、单体后端应用中,有一个典型的特征,即路由是由框架来分发的,框架将路由指定到对应的组件或者内部服务中。微服务在这个过程中做的事情是,将调用由函数调用变成了远程调用,诸如远程 HTTP 调用。而微前端呢,也是类似的,它是将应用内的组件调用变成了更细粒度的应用间组件调用,即原先我们只是将路由分发到应用的组件执行,现在则需要根据路由来找到对应的应用,再由应用分发到对应的组件上。

后端:函数调用 -> 远程调用

在大多数的 CRUD 类型的 Web 应用中,也都存在一些极为相似的模式,即:首页 -> 列表 -> 详情:

  • 首页,用于面向用户展示特定的数据或页面。这些数据通常是有限个数的,并且是多种模型的。
  • 列表,即数据模型的聚合,其典型特点是某一类数据的集合,可以看到尽可能多的数据概要(如 Google 只返回 100 页),典型见 Google、淘宝、京东的搜索结果页。
  • 详情,展示一个数据的尽可能多的内容。

如下是一个 Spring 框架,用于返回首页的示例:

@RequestMapping(value="/")
public ModelAndView homePage(){
   return new ModelAndView("/WEB-INF/jsp/index.jsp");
}

对于某个详情页面来说,它可能是这样的:

@RequestMapping(value="/detail/{detailId}")
public ModelAndView detail(HttpServletRequest request, ModelMap model){
   ....
   return new ModelAndView("/WEB-INF/jsp/detail.jsp", "detail", detail);
}

那么,在微服务的情况下,它则会变成这样子:

@RequestMapping("/name")
public String name(){
    String name = restTemplate.getForObject("http://account/name", String.class);
    return Name" + name;
}

而后端在这个过程中,多了一个服务发现的服务,来管理不同微服务的关系。

前端:组件调用 -> 应用调用

在形式上来说,单体前端框架的路由和单体后端应用,并没有太大的区别:依据不同的路由,来返回不同页面的模板。

const appRoutes: Routes = [
  { path: 'index', component: IndexComponent },
  { path: 'detail/:id', component: DetailComponent },
];

而当我们将之微服务化后,则可能变成应用 A 的路由:

const appRoutes: Routes = [
  { path: 'index', component: IndexComponent },
];

外加之应用 B 的路由:

const appRoutes: Routes = [
  { path: 'detail/:id', component: DetailComponent },
];

而问题的关键就在于:怎么将路由分发到这些不同的应用中去。与此同时,还要负责管理不同的前端应用。

路由分发式微前端

路由分发式微前端,即通过路由将不同的业务分发到不同的、独立前端应用上。其通常可以通过 HTTP 服务器的反向代理来实现,又或者是应用框架自带的路由来解决。

就当前而言,通过路由分发式的微前端架构应该是采用最多、最易采用的 “微前端” 方案。但是这种方式看上去更像是多个前端应用的聚合,即我们只是将这些不同的前端应用拼凑到一起,使他们看起来像是一个完整的整体。但是它们并不是,每次用户从 A 应用到 B 应用的时候,往往需要刷新一下页面。

在几年前的一个项目里,我们当时正在进行遗留系统重写。我们制定了一个迁移计划:

  1. 首先,使用静态网站生成动态生成首页
  2. 其次,使用 React 计划栈重构详情页
  3. 最后,替换搜索结果页

整个系统并不是一次性迁移过去,而是一步步往下进行。因此在完成不同的步骤时,我们就需要上线这个功能,于是就需要使用 Nginx 来进行路由分发。

如下是一个基于路由分发的 Nginx 配置示例:

http {
  server {
    listen       80;
    server_name  www.phodal.com;
    location /api/ {
      proxy_pass http://http://172.31.25.15:8000/api;
    }
    location /web/admin {
      proxy_pass http://172.31.25.29/web/admin;
    }
    location /web/notifications {
      proxy_pass http://172.31.25.27/web/notifications;
    }
    location / {
      proxy_pass /;
    }
  }
}

在这个示例里,不同的页面的请求被分发到不同的服务器上。

随后,我们在别的项目上也使用了类似的方式,其主要原因是:跨团队的协作。当团队达到一定规模的时候,我们不得不面对这个问题。除此,还有 Angluar 跳崖式升级的问题。于是,在这种情况下,用户前台使用 Angular 重写,后台继续使用 Angular.js 等保持再有的技术栈。在不同的场景下,都有一些相似的技术决策。

因此在这种情况下,它适用于以下场景:

  • 不同技术栈之间差异比较大,难以兼容、迁移、改造
  • 项目不想花费大量的时间在这个系统的改造上
  • 现有的系统在未来将会被取代
  • 系统功能已经很完善,基本不会有新需求

而在满足上面场景的情况下,如果为了更好的用户体验,还可以采用 iframe 的方式来解决。

使用 iFrame 创建容器

iFrame 作为一个非常古老的,人人都觉得普通的技术,却一直很管用。

HTML 内联框架元素<iframe> 表示嵌套的正在浏览的上下文,能有效地将另一个 HTML 页面嵌入到当前页面中。

iframe 可以创建一个全新的独立的宿主环境,这意味着我们的前端应用之间可以相互独立运行。采用 iframe 有几个重要的前提:

  • 网站不需要 SEO 支持
  • 拥有相应的应用管理机制

如果我们做的是一个应用平台,会在我们的系统中集成第三方系统,或者多个不同部门团队下的系统,显然这是一个不错的方案。一些典型的场景,如传统的 Desktop 应用迁移到 Web 应用:

Angular Tabs 示例

如果这一类应用过于复杂,那么它必然是要进行微服务化的拆分。因此,在采用 iframe 的时候,我们需要做这么两件事:

  • 设计管理应用机制
  • 设计应用通讯机制

加载机制。在什么情况下,我们会去加载、卸载这些应用;在这个过程中,采用怎样的动画过渡,让用户看起来更加自然。

通讯机制。直接在每个应用中创建 postMessage 事件并监听,并不是一个友好的事情。其本身对于应用的侵入性太强,因此通过 iframeEl.contentWindow 去获取 iFrame 元素的 Window 对象是一个更简化的做法。随后,就需要定义一套通讯规范:事件名采用什么格式、什么时候开始监听事件等等。

有兴趣的读者,可以看看笔者之前写的微前端框架:Mooa

不管怎样,iframe 对于我们今年的 KPI 怕是带不来一丝的好处,那么我们就去造个轮子吧。

自制框架兼容应用

不论是基于 Web Components 的 Angular,或者是 VirtualDOM 的 React 等,现有的前端框架都离不开基本的 HTML 元素 DOM。

那么,我们只需要:

  1. 在页面合适的地方引入或者创建 DOM
  2. 用户操作时,加载对应的应用(触发应用的启动),并能卸载应用。

第一个问题,创建 DOM 是一个容易解决的问题。而第二个问题,则一点儿不容易,特别是移除 DOM 和相应应用的监听。当我们拥有一个不同的技术栈时,我们就需要有针对性设计出一套这样的逻辑。

尽管 Single-SPA 已经拥有了大部分框架(如 React、Angular、Vue 等框架)的启动和卸载处理,但是它仍然不是适合于生产用途。当我基于 Single-SPA 为 Angular 框架设计一个微前端架构的应用时,我最后选择重写一个自己的框架,即 Mooa

虽然,这种方式的上手难度相对比较高,但是后期订制及可维护性比较方便。在不考虑每次加载应用带来的用户体验问题,其唯一存在的风险可能是:第三方库不兼容

但是,不论怎样,与 iFrame 相比,其在技术上更具有可吹牛逼性,更有看点。同样的,与 iframe 类似,我们仍然面对着一系列的不大不小的问题:

  • 需要设计一套管理应用的机制。
  • 对于流量大的 toC 应用来说,会在首次加载的时候,会多出大量的请求

而我们即又要拆分应用,又想 blabla……,我们还能怎么做?

组合式集成:将应用微件化

组合式集成,即通过软件工程的方式在构建前、构建时、构建后等步骤中,对应用进行一步的拆分,并重新组合。

从这种定义上来看,它可能算不上并不是一种微前端——它可以满足了微前端的三个要素,即:独立运行独立开发独立部署。但是,配合上前端框架的组件 Lazyload 功能——即在需要的时候,才加载对应的业务组件或应用,它看上去就是一个微前端应用。

与此同时,由于所有的依赖、Pollyfill 已经尽可能地在首次加载了,CSS 样式也不需要重复加载。

常见的方式有:

  • 独立构建组件和应用,生成 chunk 文件,构建后再归类生成的 chunk 文件。(这种方式更类似于微服务,但是成本更高)
  • 开发时独立开发组件或应用,集成时合并组件和应用,最后生成单体的应用。
  • 在运行时,加载应用的 Runtime,随后加载对应的应用代码和模板。

应用间的关系如下图所示(其忽略图中的 “前端微服务化”):

组合式集成对比

这种方式看上去相当的理想,即能满足多个团队并行开发,又能构建出适合的交付物。

但是,首先它有一个严重的限制:必须使用同一个框架。对于多数团队来说,这并不是问题。采用微服务的团队里,也不会因为微服务这一个前端,来使用不同的语言和技术来开发。当然了,如果要使用别的框架,也不是问题,我们只需要结合上一步中的自制框架兼容应用就可以满足我们的需求。

其次,采用这种方式还有一个限制,那就是:规范!规范!规范!。在采用这种方案时,我们需要:

  • 统一依赖。统一这些依赖的版本,引入新的依赖时都需要一一加入。
  • 规范应用的组件及路由。避免不同的应用之间,因为这些组件名称发生冲突。
  • 构建复杂。在有些方案里,我们需要修改构建系统,有些方案里则需要复杂的架构脚本。
  • 共享通用代码。这显然是一个要经常面对的问题。
  • 制定代码规范。

因此,这种方式看起来更像是一个软件工程问题。

现在,我们已经有了四种方案,每个方案都有自己的利弊。显然,结合起来会是一种更理想的做法。

考虑到现有及常用的技术的局限性问题,让我们再次将目光放得长远一些。

纯 Web Components 技术构建

在学习 Web Components 开发微前端架构的过程中,我尝试去写了我自己的 Web Components 框架:oan。在添加了一些基本的 Web 前端框架的功能之后,我发现这项技术特别适合于作为微前端的基石

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的 Web 应用中使用它们。

它主要由四项技术组件:

  • Custom elements,允许开发者创建自定义的元素,诸如 <today-news></today-news>。
  • Shadow DOM,即影子 DOM,通常是将 Shadow DOM 附加到主文档 DOM 中,并可以控制其关联的功能。而这个 Shadow DOM 则是不能直接用其它主文档 DOM 来控制的。
  • HTML templates,即 <template><slot> 元素,用于编写不在页面中显示的标记模板。
  • HTML Imports,用于引入自定义组件。

每个组件由 link 标签引入:

<link rel="import" href="components/di-li.html">
<link rel="import" href="components/d-header.html">

随后,在各自的 HTML 文件里,创建相应的组件元素,编写相应的组件逻辑。一个典型的 Web Components 应用架构如下图所示:

Web Components 架构

可以看到这边方式与我们上面使用 iframe 的方式很相似,组件拥有自己独立的 ScriptsStyles,以及对应的用于单独部署组件的域名。然而它并没有想象中的那么美好,要直接使用 Web Components 来构建前端应用的难度有:

  • 重写现有的前端应用。是的,现在我们需要完成使用 Web Components 来完成整个系统的功能。
  • 上下游生态系统不完善。缺乏相应的一些第三方控件支持,这也是为什么 jQuery 相当流行的原因。
  • 系统架构复杂。当应用被拆分为一个又一个的组件时,组件间的通讯就成了一个特别大的麻烦。

Web Components 中的 ShadowDOM 更像是新一代的前端 DOM 容器。而遗憾的是并不是所有的浏览器,都可以完全支持 Web Components。

结合 Web Components 构建

Web Components 离现在的我们太远,可是结合 Web Components 来构建前端应用,则更是一种面向未来演进的架构。或者说在未来的时候,我们可以开始采用这种方式来构建我们的应用。好在,已经有框架在打造这种可能性。

就当前而言,有两种方式可以结合 Web Components 来构建微前端应用:

  • 使用 Web Components 构建独立于框架的组件,随后在对应的框架中引入这些组件
  • 在 Web Components 中引入现有的框架,类似于 iframe 的形式

前者是一种组件式的方式,或者则像是在迁移未来的 “遗留系统” 到未来的架构上。

在 Web Components 中集成现有框架

现有的 Web 框架已经有一些可以支持 Web Components 的形式,诸如 Angular 支持的 createCustomElement,就可以实现一个 Web Components 形式的组件:

platformBrowser()
    .bootstrapModuleFactory(MyPopupModuleNgFactory)
        .then(({injector}) => {
            const MyPopupElement = createCustomElement(MyPopup, {injector});
            customElements.define(‘my-popup’, MyPopupElement);
});

在未来,将有更多的框架可以使用类似这样的形式,集成到 Web Components 应用中。

集成在现有框架中的 Web Components

另外一种方式,则是类似于 Stencil 的形式,将组件直接构建成 Web Components 形式的组件,随后在对应的诸如,如 React 或者 Angular 中直接引用。

如下是一个在 React 中引用 Stencil 生成的 Web Components 的例子:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

import 'test-components/testcomponents';

ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();

在这种情况之下,我们就可以构建出独立于框架的组件。

同样的 Stencil 仍然也只是支持最近的一些浏览器,比如:Chrome、Safari、Firefox、Edge 和 IE11

复合型

复合型,对就是上面的几个类别中,随便挑几种组合到一起。

我就不废话了~~。

结论

那么,我们应该用哪种微前端方案呢?答案见下一篇《微前端快速选型指南》

相关资料:

查看原文

赞 150 收藏 186 评论 5

独钓寒江雪 收藏了文章 · 2020-12-09

一文带你理解:可以迭代大部分数据类型的 for…of 为什么不能遍历普通对象?

for…of 及其使用

  我们知道,ES6 中引入 for...of 循环,很多时候用以替代 for...inforEach() ,并支持新的迭代协议。for...of 允许你遍历 Array(数组), String(字符串), Map(映射), Set(集合),TypedArray(类型化数组)、arguments、NodeList对象、Generator等可迭代的数据结构等。for...of语句在可迭代对象上创建一个迭代循环,调用自定义迭代钩子,并为每个不同属性的执行语句。

for...of的语法:

for (variable of iterable) {
    // statement
}
// variable:每个迭代的属性值被分配给该变量。
// iterable:一个具有可枚举属性并且可以迭代的对象。

常用用法

{
  // 迭代字符串
  const iterable = 'ES6';
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // "E"
  // "S"
  // "6"
}
{
  // 迭代数组
  const iterable = ['a', 'b'];
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // a
  // b
}
{
  // 迭代Set(集合)
  const iterable = new Set([1, 2, 2, 1]);
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // 1
  // 2
}
{
  // 迭代Map
  const iterable = new Map([["a", 1], ["b", 2], ["c", 3]]);
  for (const entry of iterable) {
    console.log(entry);
  }
  // Output:
  // ["a", 1]
  // ["b", 2]
  // ["c", 3]

  for (const [key, value] of iterable) {
    console.log(value);
  }
  // Output:
  // 1
  // 2
  // 3
}
{
  // 迭代Arguments Object(参数对象)
  function args() {
    for (const arg of arguments) {
      console.log(arg);
    }
  }
  args('a', 'b');
  // Output:
  // a
  // b
}
{
  // 迭代生成器
  function* foo(){ 
    yield 1; 
    yield 2; 
    yield 3; 
  }; 

  for (let o of foo()) { 
    console.log(o); 
  }
  // Output:
  // 1
  // 2
  // 3
}

Uncaught TypeError: obj is not iterable

// 普通对象
const obj = {
  foo: 'value1',
  bar: 'value2'
}
for(const item of obj){
  console.log(item)
}
// Uncaught TypeError: obj is not iterable

  可以看出,for of可以迭代大部分对象甚至字符串,却不能遍历普通对象。

如何用for...of迭代普通对象

  通过前面的基本用法,我们知道,for...of可以迭代数组、Map等数据结构,顺着这个思路,我们可以结合对象的Object.values()Object.keys()Object.entries()方法以及解构赋值的知识来用for...of遍历普通对象。

  • Object.values()Object.keys()Object.entries()用法及返回值
const obj = {
  foo: 'value1',
  bar: 'value2'
}
// 打印由value组成的数组
console.log(Object.values(obj)) // ["value1", "value2"]

// 打印由key组成的数组
console.log(Object.keys(obj)) // ["foo", "bar"]

// 打印由[key, value]组成的二维数组
// copy(Object.entries(obj))可以把输出结果直接拷贝到剪贴板,然后黏贴
console.log(Object.entries(obj)) // [["foo","value1"],["bar","value2"]]
  • 因为for...of可以迭代数组和Map,所以我们得到以下遍历普通对象的方法
const obj = {
  foo: 'value1',
  bar: 'value2'
}
// 方法一:使用for of迭代Object.entries(obj)形成的二维数组,利用解构赋值得到value
for(const [, value] of Object.entries(obj)){
  console.log(value) // value1, value2
}

// 方法二:Map
// 普通对象转Map
// Map 可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组
console.log(new Map(Object.entries(obj)))

// 遍历普通对象生成的Map
for(const [, value] of new Map(Object.entries(obj))){
  console.log(value) // value1, value2
}

// 方法三:继续使用for in
for(const key in obj){
  console.log(obj[key]) // value1, value2
}

{
  // 方法四:将【类数组(array-like)对象】转换为数组
  // 该对象需具有一个 length 属性,且其元素必须可以被索引。
  const obj = {
    length: 3, // length是必须的,否则什么也不会打印
    0: 'foo',
    1: 'bar',
    2: 'baz',
    a: 12  // 非数字属性是不会打印的
  };
  const array = Array.from(obj); // ["foo", "bar", "baz"]
  for (const value of array) { 
      console.log(value);
  }
  // Output: foo bar baz
}
{
  // 方法五:给【类数组】部署数组的[Symbol.iterator]方法【对普通字符串属性对象无效】
  const iterable = {
    0: 'a',
    1: 'b',
    2: 'c',
    length: 3,
    [Symbol.iterator]: Array.prototype[Symbol.iterator]
  };
  for (let item of iterable) {
    console.log(item); // 'a', 'b', 'c'
  }
}

注意事项

  • 有别于不可终止遍历的forEachfor...of的循环可由breakthrowcontinuereturn终止,在这些情况下,迭代器关闭。
  const obj = {
    foo: 'value1',
    bar: 'value2',
    baz: 'value3'
  }
  for(const [, value] of Object.entries(obj)){
    if (value === 'value2') break // 不会再执行下次迭代
    console.log(value) // value1
  };
  [1,2].forEach(item => {
      if(item == 1) break // Uncaught SyntaxError: Illegal break statement
      console.log(item)
  });
  [1,2].forEach(item => {
      if(item == 1) continue // Uncaught SyntaxError: Illegal continue statement: no surrounding iteration statement
      console.log(item)
  });
  [1,2].forEach(item => {
      if(item == 1) return // 仍然会继续执行下一次循环,打印2
      console.log(item) // 2
  })
  • For…ofFor…in对比

    • for...in 不仅枚举数组声明,它还从构造函数的原型中查找继承的非枚举属性;
    • for...of 不考虑构造函数原型上的不可枚举属性(或者说for...of语句遍历可迭代对象定义要迭代的数据。);
    • for...of 更多用于特定的集合(如数组等对象),但不是所有对象都可被for...of迭代。
      Array.prototype.newArr = () => {};
      Array.prototype.anotherNewArr = () => {};
      const array = ['foo', 'bar', 'baz'];
      for (const value in array) { 
        console.log(value); // 0 1 2 newArr anotherNewArr
      }
      for (const value of array) { 
        console.log(value); // 'foo', 'bar', 'baz'
      }

普通对象为何不能被 for of 迭代

  前面我们有提到一个词叫“可迭代”数据结构,当用for of迭代普通对象时,也会报一个“not iterable”的错误。实际上,任何具有 Symbol.iterator 属性的元素都是可迭代的。我们可以简单查看几个可被for of迭代的对象,看看和普通对象有何不同:

iterator1

iterator2

iterator3

  可以看到,这些可被for of迭代的对象,都实现了一个Symbol(Symbol.iterator)方法,而普通对象没有这个方法。

  简单来说,for of 语句创建一个循环来迭代可迭代的对象,可迭代的对象内部实现了Symbol.iterator方法,而普通对象没有实现这一方法,所以普通对象是不可迭代的。

Iterator(遍历器)

  关于Iterator(遍历器)的概念,可以参照阮一峰大大的《ECMAScript 6 入门》——Iterator(遍历器)的概念

iterator

  简单来说,ES6 为了统一集合类型数据结构的处理,增加了 iterator 接口,供 for...of 使用,简化了不同结构数据的处理。而 iterator 的遍历过程,则是类似 Generator 的方式,迭代时不断调用next方法,返回一个包含value(值)和done属性(标识是否遍历结束)的对象。

如何实现Symbol.iterator方法,使普通对象可被 for of 迭代

  依据上文的指引,我们先看看数组的Symbol.iterator接口:

const arr = [1,2,3];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: undefined, done: true}

  我们可以尝试给普通对象实现一个Symbol.iterator接口:

// 普通对象
const obj = {
  foo: 'value1',
  bar: 'value2',
  [Symbol.iterator]() {
    // 这里Object.keys不会获取到Symbol.iterator属性,原因见下文
    const keys = Object.keys(obj); 
    let index = 0;
    return {
      next: () => {
        if (index < keys.length) {
          // 迭代结果 未结束
          return {
            value: this[keys[index++]],
            done: false
          };
        } else {
          // 迭代结果 结束
          return { value: undefined, done: true };
        }
      }
    };
  }
}
for (const value of obj) {
  console.log(value); // value1 value2
};

  上面给obj实现了Symbol.iterator接口后,我们甚至还可以像下面这样把对象转换成数组:

console.log([...obj]); // ["value1", "value2"]
console.log([...{}]); // console.log is not iterable (cannot read property Symbol(Symbol.iterator))

  我们给obj对象实现了一个Symbol.iterator接口,在此,有一点需要说明的是,不用担心[Symbol.iterator]属性会被Object.keys()获取到导致遍历结果出错,因为Symbol.iterator这样的Symbol属性,需要通过Object.getOwnPropertySymbols(obj)才能获取,Object.getOwnPropertySymbols() 方法返回一个给定对象自身的所有 Symbol 属性的数组。

  有一些场合会默认调用 Iterator 接口(即Symbol.iterator方法:

  • 扩展运算符...:这提供了一种简便机制,可以将任何部署了 Iterator 接口的数据结构,转为数组。也就是说,只要某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符,将其转为数组(毫不意外的,代码[...{}]会报错,而[...'123']会输出数组['1','2','3'])。
  • 数组和可迭代对象的解构赋值(解构是ES6提供的语法糖,其实内在是针对可迭代对象Iterator接口,通过遍历器按顺序获取对应的值进行赋值。而普通对象解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。);
  • yield*_yield*后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口;
  • 由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用;
  • 字符串是一个类似数组的对象,也原生具有Iterator接口,所以也可被for of迭代。

迭代器模式

  迭代器模式提供了一种方法顺序访问一个聚合对象中的各个元素,而又无需暴露该对象的内部实现,这样既可以做到不暴露集合的内部结构,又可让外部代码透明地访问集合内部的数据。迭代器模式为遍历不同的集合结构提供了一个统一的接口,从而支持同样的算法在不同的集合结构上进行操作。

  不难发现,Symbol.iterator实现的就是一种迭代器模式。集合对象内部实现了Symbol.iterator接口,供外部调用,而我们无需过多的关注集合对象内部的结构,需要处理集合对象内部的数据时,我们通过for of调用Symbol.iterator接口即可。

  比如针对前文普通对象的Symbol.iterator接口实现一节的代码,如果我们对obj里面的数据结构进行了如下调整,那么,我们只需对应的修改供外部迭代使用的Symbol.iterator接口,即可不影响外部迭代调用:

const obj = {
  // 数据结构调整
  data: ['value1', 'value2'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.data.length) {
          // 迭代结果 未结束
          return {
            value: this.data[index++],
            done: false
          };
        } else {
          // 迭代结果 结束
          return { value: undefined, done: true };
        }
      }
    };
  }
}
// 外部调用
for (const value of obj) {
  console.log(value); // value1 value2
}

  实际使用时,我们可以把上面的Symbol.iterator提出来进行单独封装,这样就可以对一类数据结构进行迭代操作了。当然,下面的代码只是最简单的示例,你可以在此基础上探究更多实用的技巧。

const obj1 = {
  data: ['value1', 'value2']
}
const obj2 = {
  data: [1, 2]
}
// 遍历方法
consoleEachData = (obj) => {
  obj[Symbol.iterator] = () => {
    let index = 0;
    return {
      next: () => {
        if (index < obj.data.length) {
          return {
            value: obj.data[index++],
            done: false
          };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
  for (const value of obj) {
    console.log(value);
  }
}
consoleEachData(obj1); // value1 value2
consoleEachData(obj2); // 1  2

一点补充

  在写这篇文章时,有个问题给我带来了困扰:原生object对象默认没有部署Iterator接口,即object不是一个可迭代对象。对象的扩展运算符...等同于使用Object.assign()方法,这个比较好理解。那么,原生object对象的解构赋值又是怎样一种机制呢?

let aClone = { ...a };
// 等同于
let aClone = Object.assign({}, a);

  有一种说法是:ES6提供了Map数据结构,实际上原生object对象被解构时,会被当作Map进行解构。关于这点,大家有什么不同的观点吗?欢迎评论区一起探讨。

同时,ECMAScript后面又引入了异步迭代器for await...of 语句,该语句创建一个循环,该循环遍历异步可迭代对象以及同步可迭代对象,详情可查看MDN:for-await...of

参考资料

本文首发于个人博客,欢迎指正和star

查看原文

独钓寒江雪 收藏了文章 · 2020-12-08

关于前端开发的资源推荐与总结【持续更新】

我们很多人总会有这样一个问题,就是喜欢收藏很多东西,自我安慰说等有时间了一定好好看,以至于网页收藏夹、微信收藏栏、百度网盘等处积累了太多资源,给人一种学富五车的样子,而只有自己才知道,被收藏的东西,真正看了多少,掌握了多少
对于我本人来讲,可以毫不夸张的说,如果能将自己收藏的东西完全学习掌握,至少会是某个领域的专家,我想,对于大多说人来说,情况可能和我也有很大的相似之处。所以说,我们需要时刻激励自己,不能让类似“先收藏了,等以后有时间再看”这样的话语来腐蚀自己、消磨自己,最终让只是随手收藏而不去阅读成为一种习惯
关于我的一些基本情况,有兴趣的可以看看我上次因为失眠,凌晨三四点发的提问,也请大家多提建议,多指教:诚意求教:关于前端/数据分析求职的一些问题

这篇文章将会持续更新,主要是分享我本人在学习过程中看过的一些好的资源、一些经验总结,希望能够和大家更多的交流,共同进步。
一些内容,可直接移步我的掘金社区收藏夹segmentfault收藏夹


学习网站和APP推荐

入门:

  • 建议使用w3cschool慕课网
  • 推荐理由:w3cschool提供的教程全面、基础,且每个知识点都通过在线编辑器内置了小案例,只需点击“尝试一下”,便可直接在线运行,也可以在里面对提供的代码进行更改编辑,形象直观的对知识点进行实践掌握,不用自己一个个去写demo,便捷高效。在首页“编程学院”里还提供了“编程实战训练”,用的是著名的freecodecamp的项目,通关可以申请证书。同时,w3cschool还提供了编程微课、代码实例、测验等等内容,不再赘述,大家可以自行体验,w3cschool也提供有手机客户端。这也算是我入门的网站之一,目前经验值榜第二,阅读、贡献值榜均前20(不过总感觉这个网站的贡献值榜有些虚,在上面提问回答也很慢)。
  • 慕课网主要提供视频教程,同时,课程里也穿插有很多类似w3cschool的边学边练,也有手机客户端可供使用。

视频学习:

  • 主推腾讯课堂、51CTO学院和网易云课堂,至于课程,可根据评价、热度等进行筛选。

零散阅读提高:

  • 学习编程,就是一个随时随地,利用零散时间进行提高的过程,w3cschool手机APP可以满足零散时间的基础学习,掘金社区也有很多优质内容(有掘金手机客户端可供使用),这个社区的一大好处是你可以通过“标签管理”进入你想关注的领域,根据文章热度去选择阅读,好文无数,极力推荐掘金手机客户端。还有一个就是segmentfault,也就是你现在正在看文章的这个网站,我通常的做法是用微信打开思否阅读相关文章,很便捷,手机客户端目前做的还不够好。

好文推荐

  • 好文推荐这一部分,我主要想把它用来分享自己阅读过的一些优质文章,力求通俗易懂的让每个人理解前端开发领域一些核心的、底层的东西,共同进步。【这一部分将持续更新】

HTML和CSS:

  • 史上最全的前端资源大汇总:从HTML到JS,从jQuery到Vue,从node到PHP,从正则表达式到求职面试,可以说从类目上来看,应有尽有,不过似乎没有网络安全方面的内容。优点是多而全,缺点也是太多,有时间的可以慢慢看看,无时间可忽略,毕竟我觉得学习前端,需要利用的是零散时间。
  • CSS 常见布局方式:虽说是常见布局方式,但是对传统布局方式(通过盒模型),使用 display 属性(文档流布局) + position 属性(定位布局) + float属性(浮动布局)的布局没有过多的提及,重点讲了 flex 布局和 grid 布局,以及 CSS 常见的居中方式和两种经典的布局方式“圣杯布局”和“双飞翼布局”。笔者觉得文章开头的思维导图很是受用。
  • 如何实现 font-size 的响应式:实现响应式布局的方式有很多,这一篇文章讲述了使用rem、calc等进行实现,比较通俗易懂,值得参考学习。

JavaScript:

  • ECMAScript 6 入门:阮一峰老师的ES6入门,目前最好的ES6教材,没有之一。
  • JavaScript八张思维导图:5年前端开发经验的前辈的实力总结,值得拜读,通过思维导图,可以更好的回忆知识,建立知识体系。
  • JavaScript 开发人员需要知道的简写技巧:实用,有逼格,显水准的简写。
  • js 深拷贝 vs 浅拷贝:文章主要讲了 js 的基本数据类型以及一些堆和栈的知识,以及什么是深拷贝、什么是浅拷贝,深拷贝与浅拷贝的区别,怎么进行深拷贝和浅拷贝。堆和栈是数据结构里面的内容。我相信,通过这篇文章,你一定能够真正理解深拷贝 和 浅拷贝
  • JS正则表达式完整教程:关于正则表达式,网上的资源很多,类似《正则表达式30分钟入门》等等,我认为这些都只是讲了正则表达式的用法,并不能让每个人完全理解和领会正则表达式,而这篇文章,我觉得算是目前我见到的最好的正则表达式教程了,没有之一。文章略长,作者在前三章详细而通俗的讲解了正则表达式的字符匹配、位置匹配、括号的作用这些其他教程中都存在的东西(但比其他教程更容易理解),第四章讲解了正则表达式回溯法原理,让你对正则表达式的匹配原理有更清晰的认识,加深对正则的理解与掌握;最后三章正则表达式的拆分、构建、编程则是真正让你学以致用,避免纸上谈兵。这篇教程虽略长,但是不可多得。我认为:关于正则表达式,这篇文章,就够了。
  • JavaScript算法和数据结构:这个是GitHub上的项目,优点是对算法和数据结构讲的比较全面,图文并茂,缺点是很多是英文的,需要一定英语基础。
  • this、apply、call、bind:如果对this、apply、call、bind还有疑问,你可以看看这个,作者解析的很具体很到位。
  • 破解前端面试:闭包DOM如何搞定纸上代码环节:模拟真实面试环节,选择一个切入点,层层递进,详细解读。
  • 几道高级前端面试题解析:这篇文章,将告诉你学习开发过程中, 0.1 + 0.2 != 0.3的原理,以及Event loop等内容。
  • 比较 Angular、React、Vue 三剑客:如果学习过程中。你还在纠结 Angular、React、Vue 该学习哪个时,我建议你可以先看看这个。
  • JavaScript、underscore、ES6等系列:GitHub上一个已经拥有近6000star,近1000fork的项目,主要包括JavaScript深入系列、JavaScript专题系列、ES6系列、React系列等,如果你愿意,你可以关注和学习。
  • JavaScript收藏:这是我在掘金社区关于JavaScript的一些收藏,个人觉得,收藏的文章还是比较经典实用的,可以作为参考。

web前端攻击技术与防范:

笔者面试时曾被问到过这个问题,关于web前端攻击防范,我理解的思路就是:过滤、代理和转义。如果某个Web应用具备良好的安全性,那么再怎么用“不安全的AJAX”也削弱不了它的安全性,反之如果应用本身存在漏洞,不管用何种技术请求,它都是不安全的。具体可以参考下面这几篇文章:

关于React

这月成功入职魔都某淘宝拍档公司,算是对自己一直以来自我学习的一种肯定吧。业界大多在谈,不会react就不好意说自己会前端,加之公司业务需要,也不得不选择对react这一前端高峰进行攀登,在这过程中,分享一些好的资源,共同学习进步。


由于时间关系,内容推荐先做到这里,本文后续会持续更新优化,希望能够与大家多交流。第一次发专栏,问题很多,疏漏之处,请批评指正。

最近,使用jekyll-now在GitHub上搭建了个人博客,这也算是个人在前端领域一次新的突破和尝试,希望能够贡献一些有用的东西来与大家交流。

个人博客地址

查看原文

独钓寒江雪 赞了文章 · 2020-12-08

这 10 个事例,有助于你理解 ES 中的 Promise

作者:Jay Chow
译者:前端小智
来源:jamesknelson
点赞再看,微信搜索大迁世界,B站关注前端小智这个没有大厂背景,但有着一股向上积极心态人。本文 GitHubhttps://github.com/qq44924588... 上已经收录,文章的已分类,也整理了很多我的文档,和教程资料。

最近开源了一个 Vue 组件,还不够完善,欢迎大家来一起完善它,也希望大家能给个 star 支持一下,谢谢各位了。

github 地址:https://github.com/qq44924588...

在开发中,了解 JavaScript 和 Promise 基础,有助于提高我们的编码技能,今天,我们一起来看看下面的 10 片段,相信看完这 10 个片段有助于我们对 Promise 的理解。

片段1:

const prom = new Promise((res, rej) => {
  console.log('first');
  res();
  console.log('second');
});
prom.then(() => {
  console.log('third');
});
console.log('fourth');

// first
// second
// fourth
// third

Promise同步执行,promise.then异步执行。

片段2:

const prom = new Promise((res, rej) => {
  setTimeout(() => {
    res('success');
  }, 1000);
});
const prom2 = prom.then(() => {
  throw new Error('error');
});

console.log('prom', prom);
console.log('prom2', prom2);

setTimeout(() => {
  console.log('prom', prom);
  console.log('prom2', prom2);
}, 2000);

// prom 
// Promise {<pending>}
// __proto__: Promise
// [[PromiseStatus]]: "resolved"
// [[PromiseValue]]: "success"

// 2 秒后还会在打一遍上面的两个

promise 有三种不同的状态:

  • pending
  • fulfilled
  • rejected

一旦状态更新,pending->fulfilledpending->rejected,就可以再次更改它。 prom1prom2不同,并且两者都返回新的Promise状态。

片段3:

const prom = new Promise((res, rej) => {
  res('1');
  rej('error');
  res('2');
});

prom
  .then(res => {
    console.log('then: ', res);
  })
  .catch(err => {
    console.log('catch: ', err);
  });

// then: 1

即使reject后有一个resolve调用,也只能执行一次resolvereject ,剩下的不会执行。

片段 4:

Promise.resolve(1)
  .then(res => {
    console.log(res);
    return 2;
  })
  .catch(err => {
    return 3;
  })
  .then(res => {
    console.log(res);
  });

// 1
// 2

Promises 可以链接调用,当提到链接调用 时,我们通常会考虑要返回 this,但Promises不用。 每次 promise 调用.then.catch时,默认都会返回一个新的 promise,从而实现链接调用。

片段 5:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    console.log('first')
    resolve('second')
  }, 1000)
})

const start = Date.now()
promise.then((res) => {
  console.log(res, Date.now() - start, "third")
})
promise.then((res) => {
  console.log(res, Date.now() - start, "fourth")
})

// first
// second 1054 third
// second 1054 fourth

promise 的 .then.catch可以被多次调用,但是此处Promise构造函数仅执行一次。 换句话说,一旦promise的内部状态发生变化并获得了一个值,则随后对.then.catch的每次调用都将直接获取该值。

片段 6:

const promise = Promise.resolve()
  .then(() => {
    return promise
  })
promise.catch(promise )

// [TypeError: Chaining cycle detected for promise #<Promise>]
// Uncaught SyntaxError: Identifier 'promise' has already been declared
//    at <anonymous>:1:1
// (anonymous) @ VM218:1

.then.catch返回的值不能是promise本身,否则将导致无限循环。


大家都说简历没项目写,我就帮大家找了一个项目,还附赠【搭建教程】

我和阿里云合作服务器,折扣价比较便宜:89/年,223/3年,比学生9.9每月还便宜,买了搭建个项目,熟悉技术栈比较香(老用户用家人账号买就好了,我用我妈的)推荐买三年的划算点,点击本条就可以查看


片段 7:

Promise.resolve()
  .then(() => {
    return new Error('error');
  })
  .then(res => {
    console.log('then: ', res);
  })
  .catch(err => {
    console.log('catch: ', err);
  });

// then: Error: error!
// at Promise.resolve.then (...)
// at ...

.then.catch中返回错误对象不会引发错误,因此后续的.catch不会捕获该错误对象,需要更改为以下对象之一:

return Promise.reject(new Error('error')) throw new Error('error')

因为返回任何非promise 值都将包装到一个Promise对象中,也就是说,返回new Error('error')等同于返回Promise.resolve(new Error('error'))

片段 8:

Promise.resolve(1)
  .then(2)
  .then(Promise.resolve(3))
  .then(console.log)

  // 1

.then.catch的参数应为函数,而传递非函数将导致值的结果被忽略,例如.then(2).then(Promise.resolve(3)

片段 9:

Promise.resolve()
  .then(
    function success(res) {
      throw new Error('Error after success');
    },
    function fail1(e) {
      console.error('fail1: ', e);
    }
  )
  .catch(function fail2(e) {
    console.error('fail2: ', e);
  });

//   fail2:  Error: Error after success
//     at success (<anonymous>:4:13)

.then可以接受两个参数,第一个是处理成功的函数,第二个是处理错误的函数。 .catch是编写.then的第二个参数的便捷方法,但是在使用中要注意一点:.then第二个错误处理函数无法捕获第一个成功函数和后续函数抛出的错误。 .catch捕获先前的错误。 当然,如果要重写,下面的代码可以起作用:

Promise.resolve()
  .then(function success1 (res) {
    throw new Error('success1 error')
  }, function fail1 (e) {
    console.error('fail1: ', e)
  })
  .then(function success2 (res) {
  }, function fail2 (e) {
    console.error('fail2: ', e)
  })

片段 10:

process.nextTick(() => {
  console.log('1')
})
Promise.resolve()
  .then(() => {
    console.log('2')
  })
setImmediate(() => {
  console.log('3')
})
console.log('4');

// Print 4
// Print 1
// Print 2
// Print 3

process.nextTickpromise.then都属于微任务,而setImmediate属于宏任务,它在事件循环的检查阶段执行。 在事件循环的每个阶段(宏任务)之间执行微任务,并且事件循环的开始执行一次。


代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

原文:http://jamesknelson.com/grokk...


交流

干货系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,即可看到福利,你懂的。

clipboard.png

查看原文

赞 33 收藏 25 评论 1

独钓寒江雪 回答了问题 · 2020-12-07

请问各位,为什么这个网站在 mac Safari 浏览器下访问速度打开速度都很慢, Chrome 却很快。

您好,性能优化的事儿,一两句说不清楚。建议看看这个蚂蚁金服如何把前端性能监控做到极致?
可以使用Lighthouse测试分析一下。其实除了performance外,其他部分做的还是很不错的。分析结果:
image

image

image

你可以根据Lighthouse测试分析的结果进行相应优化。

关注 2 回答 1

独钓寒江雪 回答了问题 · 2020-12-07

在react的子组件中,如何获取父组件的this

方法一:可以考虑把父组件的this传给子组件,bind的时候用传递的这个值;
方法二:使用箭头函数

class Parent extends React.Component {
    toggle = () => {
        console.log(this)
    }
    render() {
        return <Child config = {toggle: this.toggle}></Child>
    }
}

子组件使用this.props.config.toggle()调用。

关注 3 回答 2

独钓寒江雪 发布了文章 · 2020-12-07

前端装逼技巧 108 式(二)—— 不讲武德

“小马同学,背一下《陋室铭》。”“山不在高,有仙则名。水不在深,有龙则灵。斯是陋室,惟馨。”“停,怎么少了俩字?”“年轻人不讲吾德。”

系列文章汇总:

文章风格所限,引用资料部分,将在对应小节末尾标出。

第十九式:西施宜笑复宜颦,丑女效之徒累身 —— window.btoawindow.atob,命名这么随意的API可以用来干什么?

  单从命名来看,完全让人摸不着头脑的两个API,我们到底可以用他们来干些什么呢?(我甚至怀疑,如果在项目中使用这样的命名,完全可能被同事打,哈哈)

  • window.atob() 函数用来解码一个已经被base-64编码过的数据。你可以使用 window.btoa() 方法来编码一个可能在传输过程中出现问题的数据,并且在接受数据之后,使用 window.atob() 方法来将数据解码。例如:你可以把ASCII里面数值0到31的控制字符进行编码,传输和解码。
  • window.btoa():将ASCII字符串或二进制数据转换成一个base64编码过的字符串,该方法不能直接作用于Unicode字符串
  • 在各浏览器中,使用 window.btoa 对Unicode字符串进行编码都会触发一个字符越界的异常。
  • 前端可以使用这两个API对URL路由参数、敏感信息等进行转码,防止明文暴露。
let encodedData = window.btoa("Hello, world"); // 编码
console.log(encodedData);                      // SGVsbG8sIHdvcmxk
let decodedData = window.atob(encodedData);    // 解码
console.log(decodedData);                      // Hello, world
let encodeUTF = window.btoa(encodeURIComponent('啊'));
console.log(encodeUTF);                        // JUU1JTk1JThB
let decodedUTF = decodeURIComponent(atob(encodeUTF));
console.log(decodedUTF);                       // 啊
资料参考:window.atob()与window.btoa()方法实现编码与解码 | WindowOrWorkerGlobalScope.atob() | WindowOrWorkerGlobalScope.btoa()

第二十式:escapeencodeURIencodeURIComponent,这些编码 API 有什么区别?

  • escape 是对字符串(string)进行编码(而另外两种是对 URL),作用是让它们在所有电脑上可读。编码之后的效果是%XX或者%uXXXX这种形式。其中 ASCII 字母、数字、@*/+ ,这几个字符不会被编码,其余的都会。最关键的是,当你需要对 URL 编码时,请忘记这个方法,这个方法是针对字符串使用的,不适用于 URL
  • encodeURIencodeURIComponent 都是编码 URL,唯一区别就是编码的字符范围;
  • encodeURI 方法不会对下列字符编码:ASCII 字母、数字、~!@#$&*()=:/,;?+'
  • encodeURIComponent 方法不会对下列字符编码:ASCII 字母、数字、~!*()'
  • 也就是 encodeURIComponent 编码的范围更广,会将http://XXX中的//也编码,会导致 URL 不可用。(其实 java 中的 URLEncoder.encode(str,char)也类似于这个方法,会导致 URL 不可用)。
  • 使用场景:

    • 如果只是编码字符串,不和 URL 有半毛钱关系,那么用 escape,而且这个方法一般不会用到;
    • 如果你需要编码整个 URL,然后需要使用这个 URL,那么用 encodeURI
    • 当你需要编码 URL 中的参数的时候,那么 encodeURIComponent 是最好方法;
    • 某些场景下,编码之后导致URL不可用(比如笔者曾遇到预览附件时某些附件URL无法打开的问题),可尝试考虑是否是因为特殊字符导致的。
  • 如果不生效可以用两次编码:关于两次编码的原因
资料参考:escape、encodeURI 和 encodeURIComponent 的区别

第二十一式:这不是我访问的页面 —— 经常碰到移动端DNS域名劫持问题?来一起了解下HTTPDNS是什么,解决了什么问题吧

  对于互联网,域名是访问的第一跳,而这一跳很多时候会“失足”(尤其是移动端网络),导致访问错误内容、失败连接等,让用户在互联网上畅游的爽快瞬间消失。但凡使用域名来给用户提供服务的互联网企业,都或多或少地无法避免在有中国特色的互联网环境中遭遇到各种域名被缓存、用户跨网访问缓慢等问题。

  • DNS 解析过程:

DNS 解析过程

  • 什么HttpDNS:

  HTTPDNS 利用 HTTP 协议与 DNS 服务器交互,代替了传统的基于 UDP 协议的 DNS 交互,绕开了运营商的 Local DNS,有效防止了域名劫持,提高域名解析效率。另外,由于 DNS 服务器端获取的是真实客户端 IP 而非 Local DNS 的 IP,能够精确定位客户端地理位置、运营商信息,从而有效改进调度精确性

  • HttpDNS 主要解决的问题:

    • Local DNS 劫持:由于 HttpDns 是通过 IP 直接请求 HTTP 获取服务器 A 记录地址,不存在向本地运营商询问 domain 解析过程,所以从根本避免了劫持问题。
    • 平均访问延迟下降:由于是 IP 直接访问省掉了一次 domain 解析过程,通过智能算法排序后找到最快节点进行访问。
    • 用户连接失败率下降:通过算法降低以往失败率过高的服务器排序,通过时间近期访问过的数据提高服务器排序,通过历史访问成功记录提高服务器排序。
  • HttpDNS的原理

    • 客户端直接访问HttpDNS接口,获取业务在域名配置管理系统上配置的访问延迟最优的IP。(基于容灾考虑,还是保留次选使用运营商LocalDNS解析域名的方式);
    • 客户端获取到IP后就直接向此IP发送业务协议请求。以Http请求为例,通过在header中指定host字段,向HttpDNS返回的IP发送标准的Http请求即可。
详细资料参考:全面了解移动端DNS域名劫持等杂症:原理、根源、HttpDNS解决方案等

第二十二式:depcheck一下你的前端项目中是否存在未使用的依赖包

  很多时候,也许我们的前端项目是基于某个已有的项目进行”复制搭建“,或者直接使用UmiJS这样的企业级 react 应用框架,又或者基于Ant Design Pro等开源项目进行删改,难免会存在未使用到的依赖包被安装,拖累项目安装速度,增大项目打包体积等,这时,我们就可以考虑使用depcheck找出那些未使用的依赖包进行移除。

  • npm install depcheck -g
  • cd 到要检查的项目目录,运行 depcheck

      D:\project>depcheck
      Unused devDependencies  #未使用的依赖
        * @antv/data-set
        * echarts
        * echarts-for-react
        * qs
      * Unused devDependencies #未使用的devDependencies
        * chalk
        * enzyme
        * express
      Missing dependencies  #缺少的dependencies
        * immutability-helper: .\src\components\EditColums\EditColumnsTable.js
        * slash2: .\config\config.js
UmiJS学习参考:UmiJS | [react]初识Umi.JS

第二十三式:防止误操作,如何在组件卸载、路由跳转、页面关闭(刷新)之前进行提示

  工作中经常会有大表单填写、提交这样的需求,如果用户写了大量内容,因为误操作,刷新或者关闭了页面,填写信息用没有做缓存,此时用户的内心应该是奔溃的。

  React组件卸载、路由跳转、页面关闭(刷新)之前进行提示(如果是AntD Modal弹窗里面的表单,也可视情考虑将maskClosable属性设置为false,防止误点蒙层导致弹窗关闭):

//监听窗口事件
useEffect(() => {
  const listener = (ev) => {
    ev.preventDefault();
    ev.returnValue = '确定离开吗?';
  };
  window.addEventListener('beforeunload', listener);
  return () => {
    // 在末尾处返回一个函数
    // React 在该函数组件卸载前调用该方法(实现 componentWillUnmount)
    window.removeEventListener('beforeunload', listener);
  };
}, []);

第二十四式:不带括号也能执行函数调用?console.log\`hello world\`会打印出什么

  • 直接看结果:
console.log`hello world` // 打印出一个数组:["hello world", raw: Array(1)]
  • 再看看以下代码:
const name = 'jack'
const gender = false
// 带括号
console.log(`hey, ${name} is a ${gender ? 'girl' : 'boy'}.`) // hey, jack is a boy.
// 不带括号
console.log`hey, ${name} is a ${gender ? 'girl' : 'boy'}.` // ["hey, ", " is a ", ".", raw: Array(3)] 'jack' 'boy'

  从最后一行打印可以看出数组中的项是以'插入表达式'作为分割生成的,并且插入表答式中的内容参数,也会依次打印出来。这就是带标签的模板字符串

  • 模板字符串的语法:
// 普通
`string text`

// 换行
`string text line 1
 string text line 2`

// 插值
`string text ${expression} string text`

// 带标签的模板字符串
tag `string text ${expression} string text`
  • 可以做什么:
const name = 'jack'
const gender = false

function myTagFunc(strings, name, gender) {
    const sex = gender ? 'girl' : 'boy'
    // return 'hello world'
    return strings[0] + name + strings[1] + sex + strings[2]
}

// result 的值是myTagFunc函数的返回值
// 如果myTagFunc返回 hello world,result就是hello world
// 这样可在一定程度上避免在模板字符串内写复杂的逻辑
const result = myTagFunc`hey, ${name} is a ${gender}.`
console.log(result) // hey, jack is a boy.

  在标签函数的第一个参数中,存在一个特殊的属性raw ,我们可以通过它来访问模板字符串的原始字符串,而不经过特殊字符的替换。

function tag(strings) {
  console.log(strings.raw[0]);
}
tag`string text line 1 \n string text line 2`;// "string text line 1 \n string text line 2"
console.log`string text line 1 \n string text line 2` // ["string text line 1 ↵ string text line 2", raw: Array(1)]

原始字符串

参考资料:MDN-带标签的模板字符串 | 带标签的模板字符串

第二十五式:还在用闭包实现自增Id?何不试试优雅大气的Generator?

  • 闭包
function createIdMaker(){
    let id = 1;
    return function (){
        return id ++;
    }
}

const idMaker =  createIdMaker();

console.log(idMaker()) // 1
console.log(idMaker()) // 2
console.log(idMaker()) // 3
  • Generator
function * createIdMaker() {
  let id = 1
  while(true) yield id ++;
}
const idMaker = createIdMaker()
console.log(idMaker.next().value) // 1
console.log(idMaker.next().value) // 2
console.log(idMaker.next().value) // 3

第二十六式:年轻人不讲武德,谁动了我的对象 —— 对象属性会自己偷偷排队?

  程序员眼里只有女神,对象是不会有的,就算有,还能无可挑剔、百依百顺不成?缺对象那就new一个咯,个性化定制,绝对的理想型。控制不了现实里的对象还能控制不了new出来的对象了?事实上,你,真的不能。

  • 试想一下,下面的代码会按照什么顺序输出:
function Foo() {
  this[200] = 'test-200';
  this[1] = 'test-1';
  this[100] = 'test-100';
  this['B'] = 'bar-B';
  this[50] = 'test-50';
  this[9] = 'test-9';
  this[8] = 'test-8';
  this[3] = 'test-3';
  this[5] = 'test-5';
  this['D'] = 'bar-D';
  this['C'] = 'bar-C';
}
var bar = new Foo();

for (key in bar) {
  console.log(`index:${key}  value:${bar[key]}`);
}

  在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。我们把对象中的数字属性称为排序属性,在 Chrome V8 引擎 中被称为 elements,字符串属性就被称为常规属性,在 V8 中被称为 properties。在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性。同时 v8 将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties),不过对象内属性的数量是固定的,默认是 10 个。更多详情可参考笔者之前的一篇文章浏览器是如何工作的:Chrome V8让你更懂JavaScript —— 【V8 内部是如何存储对象的:快属性和慢属性】一节。

  • 结果揭晓
//输出:
// index:1  value:test-1
// index:3  value:test-3
// index:5  value:test-5
// index:8  value:test-8
// index:9  value:test-9
// index:50  value:test-50
// index:100  value:test-100
// index:200  value:test-200
// index:B  value:bar-B
// index:D  value:bar-D
// index:C  value:bar-C
资料参考:浏览器是如何工作的:Chrome V8让你更懂JavaScript

第二十七式:VS Code里竟然有谷歌开发者工具面板?它 和 Chrome有什么关系?

  如下图所示,我们经常用的开发工具VSCode竟与浏览器如此相像,莫非他们是失散多年的兄弟?诶,你还别说,还真有那么点意思。(点击帮助【Help】 下的 切换开发人员工具即可打开以下面板)

VSCode

  VS Code 是基于 Electron (原来叫 Atom Shell) 进行开发的。Electron 基于 Node.js(作为后端运行时)和 Chromium(作为前端渲染),使得开发者可以使用 HTML, CSS 和 JavaScript 等前端技术来开发跨平台桌面 GUI 应用程序。Atom, GitHub Desktop, Slack, Microsoft Teams, WordPress Desktop 等知名软件都是基于 Electron 开发的。Electron比你想象的更简单,如果你可以建一个网站,你就可以建一个桌面应用程序

  VS Code 的其他的主要组件有:

参考资料:vs code的界面是用的什么技术? | Electron | Electron 快速入门

第二十八式:"★★★★★☆☆☆☆☆".slice(5 - rate, 10 - rate) —— 一个正经又有点邪气的组件封装

  开始看到这行评级rate组件的代码,是在一篇充满邪气的文章信条|手撕吊打面试官系列面试题里,总觉得这个组件与那篇文章的文风不对应,甚至觉得这个实现还足够机智,值得借鉴,我是不是没救了,哈哈。

{
  let rate = 3;
  "★★★★★☆☆☆☆☆".slice(5 - rate, 10 - rate);
}
参考资料:信条|手撕吊打面试官系列面试题

第二十九式:Uncaught TypeError: obj is not iterablefor of 遍历普通对象报错,如何快速使普通对象可被 for of 遍历?

  for of可以迭代Arrays(数组), Maps(映射), Sets(集合)、NodeList对象、Generator等,甚至连Strings(字符串)都可以迭代,却不能遍历普通对象?

// 字符串
const iterable = 'ES6';
for (const value of iterable) {
  console.log(value);
}
// Output:
// "E"
// "S"
// "6"

// 普通对象
const obj = {
  foo: 'value1',
  bar: 'value2'
}
for(const item of obj){
  console.log(item)
}
// Uncaught TypeError: obj is not iterable

  我们先从对象的几个方法Object.values()Object.keys()Object.entries()看起吧:

const obj = {
  foo: 'value1',
  bar: 'value2'
}
// 打印由value组成的数组
console.log(Object.values(obj))

// 打印由key组成的数组
console.log(Object.keys(obj))

// 打印由[key, value]组成的二维数组
console.log(Object.entries(obj))

// 方法一:使用of遍历普通对象的方法
for(const [, value] of Object.entries(obj)){
  console.log(value)
}

// 普通对象转Map
console.log(new Map(Object.entries(obj)))

// 方法二:遍历普通对象生成的Map
for(const [, value] of new Map(Object.entries(obj))){
  console.log(value)
}

  普通对象为何不可被for of迭代请参考下一式。

第三十式:可以遍历绝大部分数据类型的for of为什么不能遍历普通对象?

  • 普通对象为何不可被 for of 迭代
{
  // 数组
  const iterable = ['a', 'b'];
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // a
  // b
}
{
  // Set(集合)
  const iterable = new Set([1, 2, 2, 1]);
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // 1
  // 2
}
{
// Arguments Object(参数对象)
function args() {
  for (const arg of arguments) {
    console.log(arg);
  }
}
args('a', 'b');
// Output:
// a
// b
}

iterator1

iterator2

iterator3

  可以看到,这些可被for of迭代的对象,都实现了一个Symbol(Symbol.iterator)方法,而普通对象没有这个方法。

  简单来说,for of 语句创建一个循环来迭代可迭代的对象,可迭代的对象内部实现了Symbol.iterator方法,而普通对象没有实现这一方法,所以普通对象是不可迭代的。

  • 如何实现Symbol.iterator方法,使普通对象可被 for of 迭代
// 普通对象
const obj = {
  foo: 'value1',
  bar: 'value2',
  [Symbol.iterator]() {
    // 不用担心[Symbol.iterator]属性会被Object.keys()获取到,
    // Symbol.iterator需要通过Object.getOwnPropertySymbols(obj)获取,
    // Object.getOwnPropertySymbols() 方法返回一个给定对象自身的所有 Symbol 属性的数组。
    const keys = Object.keys(obj); 
    let index = 0;
    return {
      next: () => {
        if (index < keys.length) {
          return {
            value: this[keys[index++]],
            done: false
          };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
}
for (const value of obj) {
  console.log(value); // value1 value2
}

  上面给obj实现了Symbol.iterator接口后,我们甚至还可以像下面这样把对象转换成数组:

console.log([...obj]); // ["value1", "value2"]
console.log([...{}]); // console.log is not iterable (cannot read property Symbol(Symbol.iterator))

  更多关于for...of的探讨,可参考笔者的一文带你理解:可以迭代大部分数据类型的 for…of 为什么不能遍历普通对象?

参考资料:MDN:for...of | Understanding the JavaScript For...of Loop【译文】)| Iterator 和 for...of 循环

第三十一式:position定位只知道absolutefixedrelativestatic?,sticky其实可以很惊艳

  • absolute:生成绝对定位的元素,相对于 static 定位以外的第一个父元素进行定位。元素的位置通过 "left", "top", "right" 以及 "bottom" 属性进行规定。
  • fixed:生成绝对定位的元素,相对于浏览器窗口进行定位。元素的位置通过 "left", "top", "right" 以及 "bottom" 属性进行规定。
  • relative:生成相对定位的元素,相对于其正常位置进行定位。因此,"left:20" 会向元素的 LEFT 位置添加 20 像素。
  • static:默认值。没有定位,元素出现在正常的流中。
  • sticky:粘性定位,该定位基于用户滚动的位置。当元素在屏幕内,它的行为就像 position:relative;, 而当页面滚动超出目标区域时,它的表现就像 position:fixed;,它会固定在目标位置。

  position:sticky实现的惊艳吸顶效果可点击这里

// 用法:nav元素实现粘性定位
nav {
    position: -webkit-sticky;
    position: sticky;
    top: 0;
}

  使用position:sticky的过程中,也许会有一些坑,比如要想sticky生效,top属性或则left属性(看滚动方向)是必须要有明确的计算值的,否则fixed的表现不会出现。详情可参考《CSS世界》作者张鑫旭大佬的杀了个回马枪,还是说说position:sticky吧

参考资料:CSS position 属性 | 杀了个回马枪,还是说说position:sticky吧

第三十二式:傍能行仁义,莫若妾自知 —— getBoundingClientRect让你找准定位不迷失自我

  • 什么是 getBoundingClientRect

  Element.getBoundingClientRect()方法,用来描述一个元素的具体位置,该位置的四个属性都是相对于视口左上角的位置而言的。对某一节点执行该方法,它的返回值是一个 DOMRect 类型的对象。这个对象表示一个矩形盒子,它含有:left、top、right 和 bottom 等只读属性,具体含义如下图所示:

getBoundingClientRect

  • offset 和 getBoundingClientRect() 区别

    • offset 指偏移,包括这个元素在文档中占用的所有显示宽度,包括滚动条、padding、border,不包括overflow隐藏的部分;
    • offset 的方向值需要考虑到父级,如果父级是定位元素,那么子元素的offset值相对于父元素;如果父元素不是定位元素,那么子元素的offset值相对于可视区窗口;
    • offsetParent:获取当前元素的定位父元素

      • 如果当前元素的父元素,有CSS定位(position为absolute、relative、fixed),那么 offsetParent 获取的是最近的那个父元素。
      • 如果当前元素的父元素,没有CSS定位(position为absolute、relative、fixed),那么offsetParent 获取的是body
    • getBoundingClientRect() 的值只相对于可视区窗口,所以在很多场景下更容易“找准定位”。
  • 能做什么:滚动吸顶效果

  笔者写此节之前有做过一个表格分页器固定在浏览器底部、表头滚动吸顶的效果,主要参考了position:sticky属性和getBoundingClientRect。写此节查阅资料时有发现【前端词典】5 种滚动吸顶实现方式的比较(性能升级版) 这篇文章,对五种吸顶方式做了详尽的分析和对比,大家有兴趣可以看看。同时,《CSS世界》作者张鑫旭大佬在杀了个回马枪,还是说说position:sticky吧sticky定位也有详尽的介绍。本来还想在后续的章节谈谈吸顶,现在可能需要重新评估了,哈哈。

  滚动吸顶表格示例:

position

  【前端词典】5 种滚动吸顶实现方式的比较(性能升级版)一文中的getBoundingClientRect吸顶实现:

// html
<div class="pride_tab_fixed" ref="pride_tab_fixed">
    <div class="pride_tab" :class="titleFixed == true ? 'isFixed' :''">
        // some code
    </div>
</div>

// vue
export default {
    data(){
      return{
        titleFixed: false
      }
    },
    activated(){
      this.titleFixed = false;
      window.addEventListener('scroll', this.handleScroll);
    },
    methods: {
      //滚动监听,头部固定
      handleScroll: function () {
        let offsetTop = this.$refs.pride_tab_fixed.getBoundingClientRect().top;
        this.titleFixed = offsetTop < 0;
        // some code
      }
    }
  }
参考资料:getBoundingClientRect 方法 | 杀了个回马枪,还是说说position:sticky吧 | 【前端词典】5 种滚动吸顶实现方式的比较【性能升级版】 | JS 中的offset、scroll、client总结

第三十三式:Console Importer 让你的浏览器控制台成为更强大的实验场

  平时开发中,我们经常会在控制台尝试一些操作,Console Importer是一个可以在Chrome Console面板安装(引入)loadsh、moment、jQuery、React等资源的插件,语法也很简单,比如$i('moment')即可引入moment库,然后即可在控制台直接验证、使用这些库:

  • 使用示例:

import

  • 效果图:

Console Importer

  • 引入资源方法:
$i('jquery') // 直接引入
$i('jquery@2') // 指定版本
$i('https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js') // cdn地址
$i('https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css') // 引入CSS
链接:Console Importer | Chrome Web Store 地址

第三十四式:误用git reset --hard,我真的没救了吗? —— 认识一下 git reflog 时光穿梭机

  • 我们直奔主题,先看下面的问题:

  懵懂的小白花费一周时间做了git log如下所示的6个功能,每个功能对应一个commit的提交,分别是feature-1 到 feature-6”:

git1

  然后错误的执行了强制回滚,git reset --hard 2216d4e,回滚到了feature-1上,并且回滚的时候加了--hard,导致之前feature-2 到 feature-6的所有代码全部弄丢了,现在git log上显示如下:

git2

  然后,又在此基础上新添加了一个commit提交,信息叫feature-7:

git3

  请问:如何把丢失的代码feature-2 到 feature-6全部恢复回来,并且feature-7的代码也要保留

  • 接下来,我们先回忆几个git命令:

    • git reset --hard撤销工作区中所有未提交的修改内容,将暂存区与工作区都回到上一次版本,并删除之前的所有信息提交,谨慎使用 –hard 参数,它会删除回退点之前的所有信息;
    • git log 命令可以显示所有提交过的版本信息;
    • git reflog 可以查看所有分支的所有操作记录(包括已经被删除的 commit 记录和 reset 的操作);
    • git cherry-pick命令的作用,就是将指定的提交(commit)应用于其他分支。
  • 最后,给出解答:git refloggit cherry-pick

    • 首先,使用 git reflog 查看所以git操作记录,记下feature-7和feature-6的hash码。

    git4

    • 其次,git reset --hard cd52afc回滚到feature-6。此时我们已经完成了要求的一半:成功回到了feature-6上,但是feature-7没了。
    • 最后,git cherry-pick 4c97ff3,执行完成之后,feature-7的代码就回来了,大功告成。
更多git知识点推荐阅读GitHub联合创始人Scott Chacon 和 Ben Straub的开源巨作《Pro Git》

参考资料:git时光穿梭机--女神的侧颜 | git cherry-pick 教程 | Git Reset 三种模式

第三十五式:文件上传只会使用 form 和 Ant Design Upload组件?

  最近有做一个由其他部门提供接口的需求,上传文件的接口文档如下图所示,文件内容是base64格式,且要和其他参数一起传递。笔者以前做的需求,上传文件一般是通过form、Ant Design Upload组件、FormData等方式,上传成功得到一个URL,表单提交时将得到的URL传给后端;下载通过Blob、后端返回URL、发送邮件、或者前端生成Excel等方式。这次的上传使用了FileReader,简单记录相关实现。关于大文件的上传和下载,之后的章节会进行探讨。

fileupload

  • FileReader上传代码实现
  // DOM 
  <input type='file' id='file' onChange={(e) => this.uploadFile(e)} />
  // js
  uploadFile(e) {
    const file = e.target.files[0];
    const reader = new FileReader();
    // 处理loadend事件。该事件在读取操作结束时(要么成功,要么失败)触发。
    reader.onloadend = () => {
      this.setState({
        // 存储
        XXXFile: {
          // 除了name外,file中可被读取的属性还包括size、type、lastModifiedDate
          Name: file.name,
          // base64格式文件数据
          // 一次性发送大量的base64数据会导致浏览器卡顿,服务器端接收这样的数据可能也会出现问题。
          Buffer: reader.result.replace(/data.*base64[,]/, '')
        }
      })
    }
    reader.readAsDataURL(file);
  }
  • FileReader方法拓展:

    • FileReader.abort():中止读取操作。在返回时,readyState属性为DONE。
    • FileReader.readAsArrayBuffer():开始读取指定的 Blob中的内容, 一旦完成, result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象.
    • FileReader.readAsBinaryString():开始读取指定的Blob中的内容。一旦完成,result属性中将包含所读取文件的原始二进制数据。
    • FileReader.readAsDataURL():开始读取指定的Blob中的内容。一旦完成,result属性中将包含一个data: URL格式的Base64字符串以表示所读取文件的内容。
    • FileReader.readAsText():开始读取指定的Blob中的内容。一旦完成,result属性中将包含一个字符串以表示所读取的文件内容。
  • 其他文件上传参考资料:

第三十六式:2**53 === 2 ** 53 + 1?如果没有BigInt,该如何进行大数求和?

  • Number.MAX_SAFE_INTEGER:值为9007199254740991,即2 ** 53 - 1,小于该值能精确表示。然后我们会发现2**53 === 2 ** 53 + 1true
  • Number.MAX_VALUE::值为1.7976931348623157e+308,大于该值得到的是Infinity,介于Infinity和安全值之间的无法精确表示。

  既然我们不能实现直接相加,我们可以利用字符串分割成字符串数组的方式来对每一位进行相加。

  • 大数相加实现
function add (str1, str2) {
    // 转为单字符串数组
    str1=(str1+'').split('');
    str2=(str2+'').split('');
    let result='';//存储结果
    let flag=0; // 存储进位
    while(str1.length || str2.length || flag){// 是否还有没有相加的位或者大于0的进位
    // ~~str1.pop()得到最右边一位,并转成数字(~为按位取反运算符,详见第十四式)
    // 对应位数字相加,再加上进位
        flag += ~~str1.pop() + ~~str2.pop();
    // 去除进位,然后进行字符串拼接
        result = flag%10 + result;
    // 进位,0或1
        flag = +(flag>9);
    }
  // 去除开头(高位)的0
  return result.replace(/^0+/, '');
};
// 2 ** 53:9007199254740992
// add(2**53, 1): "9007199254740993"
// 2**53+1: 9007199254740992
// BigInt结果
// 2n**53n+1n:9007199254740993n
  • 加减乘除:

  关于加减乘除的实现可参考大数运算js实现,基本思路:

1、大数加法和减法是一个道理,既然我们不能实现直接相加减,我们可以利用字符串分割成字符串数组的方式。
2、乘法:每个位数两两相乘,最后错位相加。
参考资料:JS 大数相加 | 前端应该知道的JavaScript浮点数和大数的原理

本文首发于个人博客,欢迎指正和star

查看原文

赞 79 收藏 58 评论 6

独钓寒江雪 发布了文章 · 2020-12-07

一文带你理解:可以迭代大部分数据类型的 for…of 为什么不能遍历普通对象?

for…of 及其使用

  我们知道,ES6 中引入 for...of 循环,很多时候用以替代 for...inforEach() ,并支持新的迭代协议。for...of 允许你遍历 Array(数组), String(字符串), Map(映射), Set(集合),TypedArray(类型化数组)、arguments、NodeList对象、Generator等可迭代的数据结构等。for...of语句在可迭代对象上创建一个迭代循环,调用自定义迭代钩子,并为每个不同属性的执行语句。

for...of的语法:

for (variable of iterable) {
    // statement
}
// variable:每个迭代的属性值被分配给该变量。
// iterable:一个具有可枚举属性并且可以迭代的对象。

常用用法

{
  // 迭代字符串
  const iterable = 'ES6';
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // "E"
  // "S"
  // "6"
}
{
  // 迭代数组
  const iterable = ['a', 'b'];
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // a
  // b
}
{
  // 迭代Set(集合)
  const iterable = new Set([1, 2, 2, 1]);
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // 1
  // 2
}
{
  // 迭代Map
  const iterable = new Map([["a", 1], ["b", 2], ["c", 3]]);
  for (const entry of iterable) {
    console.log(entry);
  }
  // Output:
  // ["a", 1]
  // ["b", 2]
  // ["c", 3]

  for (const [key, value] of iterable) {
    console.log(value);
  }
  // Output:
  // 1
  // 2
  // 3
}
{
  // 迭代Arguments Object(参数对象)
  function args() {
    for (const arg of arguments) {
      console.log(arg);
    }
  }
  args('a', 'b');
  // Output:
  // a
  // b
}
{
  // 迭代生成器
  function* foo(){ 
    yield 1; 
    yield 2; 
    yield 3; 
  }; 

  for (let o of foo()) { 
    console.log(o); 
  }
  // Output:
  // 1
  // 2
  // 3
}

Uncaught TypeError: obj is not iterable

// 普通对象
const obj = {
  foo: 'value1',
  bar: 'value2'
}
for(const item of obj){
  console.log(item)
}
// Uncaught TypeError: obj is not iterable

  可以看出,for of可以迭代大部分对象甚至字符串,却不能遍历普通对象。

如何用for...of迭代普通对象

  通过前面的基本用法,我们知道,for...of可以迭代数组、Map等数据结构,顺着这个思路,我们可以结合对象的Object.values()Object.keys()Object.entries()方法以及解构赋值的知识来用for...of遍历普通对象。

  • Object.values()Object.keys()Object.entries()用法及返回值
const obj = {
  foo: 'value1',
  bar: 'value2'
}
// 打印由value组成的数组
console.log(Object.values(obj)) // ["value1", "value2"]

// 打印由key组成的数组
console.log(Object.keys(obj)) // ["foo", "bar"]

// 打印由[key, value]组成的二维数组
// copy(Object.entries(obj))可以把输出结果直接拷贝到剪贴板,然后黏贴
console.log(Object.entries(obj)) // [["foo","value1"],["bar","value2"]]
  • 因为for...of可以迭代数组和Map,所以我们得到以下遍历普通对象的方法
const obj = {
  foo: 'value1',
  bar: 'value2'
}
// 方法一:使用for of迭代Object.entries(obj)形成的二维数组,利用解构赋值得到value
for(const [, value] of Object.entries(obj)){
  console.log(value) // value1, value2
}

// 方法二:Map
// 普通对象转Map
// Map 可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组
console.log(new Map(Object.entries(obj)))

// 遍历普通对象生成的Map
for(const [, value] of new Map(Object.entries(obj))){
  console.log(value) // value1, value2
}

// 方法三:继续使用for in
for(const key in obj){
  console.log(obj[key]) // value1, value2
}

{
  // 方法四:将【类数组(array-like)对象】转换为数组
  // 该对象需具有一个 length 属性,且其元素必须可以被索引。
  const obj = {
    length: 3, // length是必须的,否则什么也不会打印
    0: 'foo',
    1: 'bar',
    2: 'baz',
    a: 12  // 非数字属性是不会打印的
  };
  const array = Array.from(obj); // ["foo", "bar", "baz"]
  for (const value of array) { 
      console.log(value);
  }
  // Output: foo bar baz
}
{
  // 方法五:给【类数组】部署数组的[Symbol.iterator]方法【对普通字符串属性对象无效】
  const iterable = {
    0: 'a',
    1: 'b',
    2: 'c',
    length: 3,
    [Symbol.iterator]: Array.prototype[Symbol.iterator]
  };
  for (let item of iterable) {
    console.log(item); // 'a', 'b', 'c'
  }
}

注意事项

  • 有别于不可终止遍历的forEachfor...of的循环可由breakthrowcontinuereturn终止,在这些情况下,迭代器关闭。
  const obj = {
    foo: 'value1',
    bar: 'value2',
    baz: 'value3'
  }
  for(const [, value] of Object.entries(obj)){
    if (value === 'value2') break // 不会再执行下次迭代
    console.log(value) // value1
  };
  [1,2].forEach(item => {
      if(item == 1) break // Uncaught SyntaxError: Illegal break statement
      console.log(item)
  });
  [1,2].forEach(item => {
      if(item == 1) continue // Uncaught SyntaxError: Illegal continue statement: no surrounding iteration statement
      console.log(item)
  });
  [1,2].forEach(item => {
      if(item == 1) return // 仍然会继续执行下一次循环,打印2
      console.log(item) // 2
  })
  • For…ofFor…in对比

    • for...in 不仅枚举数组声明,它还从构造函数的原型中查找继承的非枚举属性;
    • for...of 不考虑构造函数原型上的不可枚举属性(或者说for...of语句遍历可迭代对象定义要迭代的数据。);
    • for...of 更多用于特定的集合(如数组等对象),但不是所有对象都可被for...of迭代。
      Array.prototype.newArr = () => {};
      Array.prototype.anotherNewArr = () => {};
      const array = ['foo', 'bar', 'baz'];
      for (const value in array) { 
        console.log(value); // 0 1 2 newArr anotherNewArr
      }
      for (const value of array) { 
        console.log(value); // 'foo', 'bar', 'baz'
      }

普通对象为何不能被 for of 迭代

  前面我们有提到一个词叫“可迭代”数据结构,当用for of迭代普通对象时,也会报一个“not iterable”的错误。实际上,任何具有 Symbol.iterator 属性的元素都是可迭代的。我们可以简单查看几个可被for of迭代的对象,看看和普通对象有何不同:

iterator1

iterator2

iterator3

  可以看到,这些可被for of迭代的对象,都实现了一个Symbol(Symbol.iterator)方法,而普通对象没有这个方法。

  简单来说,for of 语句创建一个循环来迭代可迭代的对象,可迭代的对象内部实现了Symbol.iterator方法,而普通对象没有实现这一方法,所以普通对象是不可迭代的。

Iterator(遍历器)

  关于Iterator(遍历器)的概念,可以参照阮一峰大大的《ECMAScript 6 入门》——Iterator(遍历器)的概念

iterator

  简单来说,ES6 为了统一集合类型数据结构的处理,增加了 iterator 接口,供 for...of 使用,简化了不同结构数据的处理。而 iterator 的遍历过程,则是类似 Generator 的方式,迭代时不断调用next方法,返回一个包含value(值)和done属性(标识是否遍历结束)的对象。

如何实现Symbol.iterator方法,使普通对象可被 for of 迭代

  依据上文的指引,我们先看看数组的Symbol.iterator接口:

const arr = [1,2,3];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: undefined, done: true}

  我们可以尝试给普通对象实现一个Symbol.iterator接口:

// 普通对象
const obj = {
  foo: 'value1',
  bar: 'value2',
  [Symbol.iterator]() {
    // 这里Object.keys不会获取到Symbol.iterator属性,原因见下文
    const keys = Object.keys(obj); 
    let index = 0;
    return {
      next: () => {
        if (index < keys.length) {
          // 迭代结果 未结束
          return {
            value: this[keys[index++]],
            done: false
          };
        } else {
          // 迭代结果 结束
          return { value: undefined, done: true };
        }
      }
    };
  }
}
for (const value of obj) {
  console.log(value); // value1 value2
};

  上面给obj实现了Symbol.iterator接口后,我们甚至还可以像下面这样把对象转换成数组:

console.log([...obj]); // ["value1", "value2"]
console.log([...{}]); // console.log is not iterable (cannot read property Symbol(Symbol.iterator))

  我们给obj对象实现了一个Symbol.iterator接口,在此,有一点需要说明的是,不用担心[Symbol.iterator]属性会被Object.keys()获取到导致遍历结果出错,因为Symbol.iterator这样的Symbol属性,需要通过Object.getOwnPropertySymbols(obj)才能获取,Object.getOwnPropertySymbols() 方法返回一个给定对象自身的所有 Symbol 属性的数组。

  有一些场合会默认调用 Iterator 接口(即Symbol.iterator方法:

  • 扩展运算符...:这提供了一种简便机制,可以将任何部署了 Iterator 接口的数据结构,转为数组。也就是说,只要某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符,将其转为数组(毫不意外的,代码[...{}]会报错,而[...'123']会输出数组['1','2','3'])。
  • 数组和可迭代对象的解构赋值(解构是ES6提供的语法糖,其实内在是针对可迭代对象Iterator接口,通过遍历器按顺序获取对应的值进行赋值。而普通对象解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。);
  • yield*_yield*后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口;
  • 由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用;
  • 字符串是一个类似数组的对象,也原生具有Iterator接口,所以也可被for of迭代。

迭代器模式

  迭代器模式提供了一种方法顺序访问一个聚合对象中的各个元素,而又无需暴露该对象的内部实现,这样既可以做到不暴露集合的内部结构,又可让外部代码透明地访问集合内部的数据。迭代器模式为遍历不同的集合结构提供了一个统一的接口,从而支持同样的算法在不同的集合结构上进行操作。

  不难发现,Symbol.iterator实现的就是一种迭代器模式。集合对象内部实现了Symbol.iterator接口,供外部调用,而我们无需过多的关注集合对象内部的结构,需要处理集合对象内部的数据时,我们通过for of调用Symbol.iterator接口即可。

  比如针对前文普通对象的Symbol.iterator接口实现一节的代码,如果我们对obj里面的数据结构进行了如下调整,那么,我们只需对应的修改供外部迭代使用的Symbol.iterator接口,即可不影响外部迭代调用:

const obj = {
  // 数据结构调整
  data: ['value1', 'value2'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.data.length) {
          // 迭代结果 未结束
          return {
            value: this.data[index++],
            done: false
          };
        } else {
          // 迭代结果 结束
          return { value: undefined, done: true };
        }
      }
    };
  }
}
// 外部调用
for (const value of obj) {
  console.log(value); // value1 value2
}

  实际使用时,我们可以把上面的Symbol.iterator提出来进行单独封装,这样就可以对一类数据结构进行迭代操作了。当然,下面的代码只是最简单的示例,你可以在此基础上探究更多实用的技巧。

const obj1 = {
  data: ['value1', 'value2']
}
const obj2 = {
  data: [1, 2]
}
// 遍历方法
consoleEachData = (obj) => {
  obj[Symbol.iterator] = () => {
    let index = 0;
    return {
      next: () => {
        if (index < obj.data.length) {
          return {
            value: obj.data[index++],
            done: false
          };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
  for (const value of obj) {
    console.log(value);
  }
}
consoleEachData(obj1); // value1 value2
consoleEachData(obj2); // 1  2

一点补充

  在写这篇文章时,有个问题给我带来了困扰:原生object对象默认没有部署Iterator接口,即object不是一个可迭代对象。对象的扩展运算符...等同于使用Object.assign()方法,这个比较好理解。那么,原生object对象的解构赋值又是怎样一种机制呢?

let aClone = { ...a };
// 等同于
let aClone = Object.assign({}, a);

  有一种说法是:ES6提供了Map数据结构,实际上原生object对象被解构时,会被当作Map进行解构。关于这点,大家有什么不同的观点吗?欢迎评论区一起探讨。

同时,ECMAScript后面又引入了异步迭代器for await...of 语句,该语句创建一个循环,该循环遍历异步可迭代对象以及同步可迭代对象,详情可查看MDN:for-await...of

参考资料

本文首发于个人博客,欢迎指正和star

查看原文

赞 23 收藏 12 评论 3

独钓寒江雪 发布了文章 · 2020-11-23

前端装逼技巧 108 式(一)—— 打工人

你在拼多多到处找人砍价,他在滴滴打车求人助力,我在电子厂拧螺丝拧到凌晨,我们都有光明的未来!早安,打工人!

系列文章汇总:

楔子

  作为一名拥有钢铁般意志的前端打工人,装逼是不可能的,这辈子都不可能装逼。如果真要装逼,那就大家一起装逼,毕竟前端要讲武德嘛,要耗子尾汁。遂决定写下前端装逼技巧108式,供诸君茶余饭后一乐,时不时秀个骚操作,为打工的生活增添一抹亮色。

  因作为打工人,时间、精力有限,目前大纲只有约50式,还请诸君有好的装逼要点私信或者在评论区留言,也可在我的博客页面扫码添加微信,大家共同探讨装逼大计、共同迎接打工人的光明未来!

系列文章力求通过一行代码或者一个小的切入点去理解一个知识点,文章风格所限,引用资料部分,将在对应小节末尾标出。

第一式:子曰,公欲装逼好,工具少不了

  • 代码太丑陋,carbon来相救:把你的代码转换为精美图片进行分享(点击图片跳转)

carbon

  本文为便于代码复制,将奉行不首先装逼原则,尽量减少此装逼利器的使用。

第二式:console调试万金油,学会开车更上头

  console.log()在前端调试中的地位自不必赘述,其实一代车神也对其五体投地,不信诸君细看(如真有不解其意者,建议发扬不耻下问的求知精神,问问你旁边的同事):

image

  是的,以上图片是由console.log()完成的,我没有骗你,贴出代码以证清白,为便于诸君控制台开车,此处我们忘掉第一式:

// 在此提醒,为免于生成丑陋的锯齿背景图片,请注意空格的个数,并保证console面板的宽度。
console.log(`%c                                                                            
                                                                            
                                                                            
                               %c FBI WARNING %c                                
                                                                            
                                                                            
%c        Federal Law provides severe civil and criminal penalties for        
        the unauthorized reproduction,distribution, or exhibition of        
         copyrighted motion pictures (Title 17, United States Code,         
        Sections 501 and 508). The Federal Bureau of Investigation          
         investigates allegations of criminal copyright infringement        
                 (Title 17, United States Code, Section 506).               
                                                                            
                                                                            
                                                                            
`,
'background: #000; font-size: 18px; font-family: monospace',
'background: #f33; font-size: 18px; font-family: monospace; color: #eee; text-shadow:0 0 1px #fff',
'background: #000; font-size: 18px; font-family: monospace',
'background: #000; font-size: 18px; font-family: monospace; color: #ddd; text-shadow:0 0 2px #fff'
)

  为什么会这样呢?想必你还记得其他语言中的print()。占位符是print()的专属吗?不,他们在console.log()中同样适用:

  • %s:字符串
  • %d:整数
  • %i:整数
  • %f:浮点数
  • %o:obj对象(DOM)
  • %O:obj对象
  • %c:CSS样式

  console.log()可以通过以上这些特有的占位符进行信息的加工输出。是的,你可能已经明白,上面代码的玄机就在四个%c,第一个创建神秘而性感的纯黑背景;第二个给“FBI WARNING”加上红色的背景;第三个恢复纯黑的性感;第四个配上白色的文字,如此,大事已成。

  明白了以上原理,诸君就可以自由发挥,展示你们强大的css实力了,甚至还可以输出gif背景图,在装逼的路上更上几层楼。不装了,我是css渣渣。

console.log(
  '%c孤篷',
  'text-shadow: 0 1px 0 #ccc,0 2px 0 #c9c9c9,0 3px 0 #bbb,0 4px 0 #b9b9b9,0 5px 0 #aaa,0 6px 1px rgba(0,0,0,.1),0 0 5px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.3),0 3px 5px rgba(0,0,0,.2),0 5px 10px rgba(0,0,0,.25),0 10px 10px rgba(0,0,0,.2),0 20px 20px rgba(0,0,0,.15);font-size:5em'
  )

孤蓬

  那么,我们是否可以超越度娘,在自家公司官网控制台完成精美的招聘文案投送呢?更高级一些的console.log插图用法可以参考这里

  拓展:console对象都有哪些方法?

console

参考资料:小蝌蚪日记:通过console.log高仿FBI Warning | Using the F12 Tools Console to View Errors and Status

第三式:芙蓉面,杨柳腰,无物比妖娆 —— 让你看清UI的轮廓

  • UI轮廓哪里寻,outline属性来帮您。

    html * {
       outline: 1px solid red
    }

    UCloud

  • 解析与思考

    • 这里没有使用 border 的原因是 border 会增加元素的大小但是 outline 不会;
    • 通过这个技巧不仅能帮助我们在开发中迅速了解元素所在的位置,还能帮助我们方便地查看任意网站的布局;
    • 所有浏览器都支持 outline 属性;outline (轮廓)是绘制于元素周围的一条线,位于边框边缘的外围,可起到突出元素的作用;
    • 轮廓线不会占据空间,也不一定是矩形(比如2D转换等)。
    • 去掉Chrome浏览器中输入框以及其它表单控件获得焦点时的带颜色边框

      input {
        outline: none;
      }
  • 通过一个开关实现任意网页开启关闭outline

    • Chrome右上角三个点⇒书签⇒书签管理器⇒右上角三个点⇒「添加新书签」;
    • 名称随意,粘贴以下代码到网址中;
    • 然后我们就可以在任意网站上点击刚才创建的书签,内部会判断是否存在调试的 style。存在的话就删除,不存在的话就添加,通过这种方式我们就能很方便的通过这个技巧查看任意网页的布局了。

         javascript: (function() {
            var elements = document.body.getElementsByTagName('*');
            var items = [];
            for (var i = 0; i < elements.length; i++) {
               if (elements[i].innerHTML.indexOf('html * { outline: 1px solid red }') != -1) {
                  items.push(elements[i]);
               }
            }
            if (items.length > 0) {
               for (var i = 0; i < items.length; i++) {
                  items[i].innerHTML = '';
               }
            } else {
               document.body.innerHTML +=
                  '<style>html * { outline: 1px solid red }</style>';
            }
       })();
参考资料:很好用的 UI 调试技巧

第四式:角声寒,夜阑珊,又改需求。难,难,难!—— 类型转换助你不带脏字的骂产品、优雅的夸自己

  • (!(~+[])+{})[--[~+""][+[]]*[~+[]]+~~!+[]]+({}+[])[[~!+[]*~+[]]]:sb
  • ([][[]]+[])[+!![]]+([]+{})[!+[]+!![]]:nb
  • (+!![]*([]+{})+[]+{})[+[]]+([]+{})[!+[]+!![]]:Nb

图解:取类型转换得到的字符串里的字母进行拼凑(看懂了原理,其实我们完全可以尝试写的更简练一些)

nb

插件:zhuangbility,一个可以逆向操作,输入文字,返回操作符的npm插件

第五式:a == 1 && a == 2 && a == 3,那你可以实现a === 1 && a === 2 && a === 3吗?

  • a == 1 && a == 2 && a == 3

      // 当然,你也可以把count作为属性放在a对象上
      let count = 1
      let a = {
        valueOf: function(){return count++}
      }
      console.log(a==1 && a==2 && a==3) // true
    • 对象在转换基本类型时,会调用该对象上 valueOftoString 这两个方法,该方法的返回值是转换为基本类型的结果
    • 具体调用以上哪个方法取决于内置的 toPrimitive 调用结果
  • a === 1 && a === 2 && a === 3
let count = 1;
Object.defineProperty(window, 'a', {
    get: function() {
        return count ++;
    }
});

console.log(a===1 && a===2 && a===3) // true
Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。同时,该API也是Vue 2.x数据绑定实现的核心,Vue 在 3.x 版本之后改用 Proxy 进行实现,本系列文章后续会进行简单讨论。

原理可参考:译:在JS中,如何让(a===1 && a===2 && a === 3)(严格相等)的值为true? | 深入浅出Object.defineProperty() | ECMAScript7规范中的ToPrimitive抽象操作

第六式:最近有点儿火的 Web Components 可能并不是小鲜肉

Web Components原理:

  • html很宽松,浏览器也可以识别不规则、不合法标签(元素)(如<custom-label>Web Components</custom-label> 会展示"Web Components"。);
  • 自定义继承自HTMLElement的类,称为自定义元素的类;
  • 经过window.customElements.defineAPI定义和注册自定义元素,使得不合法标签(自定义元素)与自定义元素的类关联,实现合法化
  • 通过模板标签<template>简化类的定义过程并添加样式;
  • 通过自定义元素的attachShadow()方法开启 Shadow DOM(这部分 DOM 默认与外部 DOM 隔离,内部任何代码都无法影响外部),隐藏自定义元素的内部实现;
  • 添加事件监听、进行组件化封装等。

Web Components的好处:

  • 可以通过 shadow DOM 创建子 DOM 树,不会被页面上的 CSS 样式和 javascript 脚本所影响;
  • 便于复用/重用;
  • 相比于Vue、React、Angular等的组件化,Web Components是原生的、框架无关的。
参考资料:Web Components 入门实例教程-阮一峰 | Window.customElements | Web Components

第七式:Windows环境变量设置其实可以很简单

  使用Windows系统电脑进行开发的小伙伴也许经常会碰到需要手动设置环境变量的情况,其实设置环境变量也可以很简单的通过命令行完成:


# 查看当前所有可用的环境变量
set
# 查看某个环境变量:查看path变量的值
set path
# 修改环境变量(注意:这里是覆盖)
set 变量名=变量内容
# 设置为空
set 变量名=
# 给变量追加内容(%变量名%;代表以前的值)
set 变量名=%变量名%;变量内容
# 将C:\Go\bin\添加到path中
set path=%path%;C:\Go\bin\
参考资料:Windows使用cmd命令行查看、修改、删除与添加环境变量

第八式:1.toFixed()1.0.toFixed()1..toFixed(),究竟哪个写法是对的?

  在数字字面量中,1.xxxxx这样的语法是浮点数表示法。所以1.toFixed()这样的语法在 JavaScript 中会报错,这个错误来自于浮点数的字面量解析过程,而不是“.作为存取运算符”的处理过程。在 JavaScript 中,浮点数的小位数是可以为空的,因此“1.”和“1.0”将作为相同的浮点数被解析出来。所以会出现:

1. === 1; // true
1. === 1.0; // true
1 === 1.0; // true
1.; // 1
1.0; // 1

  既然“1.”表示的是浮点数,那么“1..toFixed”表示的就是该浮点数字面量的“.toFixed”属性。当是数字字面量时,可通过类似Number(1).toFixed() 创建基本包装类型(显示装箱),然后就可以进行属性和方法的添加、读取(或者可借助小括号把字面量括起来,告诉浏览器引擎这是一个整体)。

  • 装箱:将基本数据类型转换为对应的引用类型的操作(装箱又分为隐式装箱和显式装箱);
  • 拆箱:把引用类型转换成基本数据类型。

  基本类型不能有属性和方法,当给它们添加属性的时候系统会自动进行包装类并销毁:

var num = 1;
num.len = 2;
//上一行代码会产生如下过程:
// new Number(1).len =2; 
// delete len;
// 也就是会先添加len属性,当前语句执行结束后即销毁,所以下一行打印num还是1,没有len属性。
console.log(num, num.len);//1 undefined
var num = new Number(1);
num.len = 2;
console.log(num); // Number {1, len: 2}
参考拓展:谈谈JavaScript中装箱和拆箱

第九式:typeof不靠谱,我们又该如何判断类型?

  • typeof之殇:我们应该都知道,使用 typeof 可以准确判断除 null 以外的基本类型,以及 functionsymbol 类型;null 会被 typeof 判断为 object

    • 在 JavaScript 最初的实现中,JavaScript 中的值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是 0。由于 null 代表的是空指针(大多数平台下值为 0x00),因此,null 的类型标签是 0,typeof null 也因此返回 "object";
    • 在 ES 6 之前,typeof 总能保证对任何所给的操作数返回一个字符串。即便是没有声明的标识符,typeof 也能返回 'undefined'。使用 typeof 永远不会抛出错误。但在加入了块级作用域的 let 和 const 之后,在其被声明之前对块中的 let 和 const 变量使用 typeof 会抛出一个 ReferenceError。块作用域变量在块的头部处于“暂存死区”,直至其被初始化,在这期间,访问变量将会引发错误。
  • 以前经常拿来判断数组的instanceof是怎么实现的:使用 a instanceof B 判断的是a 是否为 B 的实例,即 a 的原型链上是否存在 B 构造函数(ES6之后可以通过Array.isArray()来判断是否是数组)。

      // L 表示左表达式,R 表示右表达式
      const customInstanceof = (L, R) => {
        if (typeof L !== 'object') return false
        while (true) { 
          // 已经遍历到了最顶端
          if (L === null) return false
          // 利用原型链进行判断
          if (R.prototype === L.__proto__) return true
          L = L.__proto__
        } 
      };
      customInstanceof([], Array) // true
  • constructor为什么不是我们的选择?

    • constructor属性是可以被修改的,会导致检测出的结果不正确;
    • 除了undefinednull,其他类型的变量均能使用constructor判断出类型。

        let bool=true;
        bool.constructor==Boolean  //true
        let num1=1;
        num1.constructor==Number  //true
        let num2=new Number();
        num2.constructor==Number   //true
        // constructor属性是可以被修改的
        num2.constructor = Object
        num2.constructor==Number   //false
        let str='hello world';
        str.constructor==String     //true
  • Object.prototype.toString竟如此万能?

      Object.prototype.toString.call(123)
      //"[object Number]"
      Object.prototype.toString.call('str')
      //"[object String]"
      Object.prototype.toString.call(true)
      //"[object Boolean]"
      Object.prototype.toString.call({})
      //"[object Object]"
      Object.prototype.toString.call([])
      //"[object Array]"
      // 定义getType方法,用来判断类型
      getType = (obj) => {
        return Object.prototype.toString.call(obj).slice(8, -1)
      }
      getType(12n) // BigInt
      getType(Symbol()) // Symbol
      getType(() => {}) // Function
      getType() // Undefined
      getType(null) // Null
      getType(NaN) // Number
资料参考:typeof | The history of “typeof null”

第十式:十进制二进制互转,真的不用那么麻烦

  • 使用NumberObject.toString(radix)十进制转二进制:

    // 如有补齐位数的需求,可通过判断返回值的长度在前面加0
    let num = 10;
    console.log(num.toString(2)); // 1010
  • 使用parseInt(string, radix);二进制转十进制:

    let num = 1010101;
    console.log(parseInt(num,2)); // 85
  • Tips:由于以上代码都使用let定义了num变量,除了刷新页面外,该如何在控制台分别执行呢?只需把代码放在一对花括号之间即可(块级作用域)。

第十一式:没有加减乘除,如何比较正整数字符串的大小?

  在接手的部分项目中,存在需要前端拼接Elasticsearch查询语句的情况,好不容易会了点Elasticsearch,却发现问题并没有那么简单:金额数量区间查询你告诉我存储的是字符串?那岂不是会出现1<3000<5的情况?天啦噜,不要逗我好吗?

  那么,在不改动ES的情况下,如何通过正则表达式查询来实现正整数字符串大小的比较呢?直接说思路:数位更多或者从高位开始比,数值更大即是更大的数【一时间没想到更好的解法,有更好的解法欢迎留言或者私信】。

// 通过正则表达式从字符串数组中筛选出大于某个数值的字符串类型数据
const filterStrNumberByRegExp = (num, arr) => {
  const strBaseNumber = num.toString();
  const arrBaseNumber = strBaseNumber.split('');
  const len = strBaseNumber.length;
  // 生成正则:数位更多或者从高位开始比,数值更大
  let strRegExp = `\\d{${len+1}}`;
  arrBaseNumber.map((item, index) => {
    // 这里因为有位数限制,'^'和'$'不是必须的,可以去除
    strRegExp += `|${strBaseNumber.substring(index,-1) || '^'}[${+item + 1}-9]\\d{${len - index - 1}}$`
  });
  // 丢给ES进行查询时,貌似不可使用\d(可用[0-9]替代)、开头、结尾匹配等字符,上面四行可用下面注释内容替换
  //let strRegExp = `[0-9]{${len+1}}`;
  //arrBaseNumber.map((item, index) => {
  //  strRegExp += `|${strBaseNumber.substring(index,-1) || ''}[${+item + 1}-9][0-9]{${len - index - 1}}`
  //});
  const regExp = new RegExp(strRegExp);
  // 丢给ES进行正则查询时使用strRegExp结果
  console.log(regExp, strRegExp);
  return arr.filter(item => {
    // 小于等于判断的话,这里取反或者自行修改正则
    if(regExp.test(item)) return true;
  });
};
filterStrNumberByRegExp(386, ['12', '334', '556', '1122', '5546','234','388','387','1234','386','385']); // ["556", "1122", "5546", "388", "387", "1234"]

  详细Elasticsearch列表页搜索公共方法实现可以查看我的这篇笔记。

第十二式:前端太寂寞?如何让页面和你说话? —— TTS(Text To Speah)

  在项目中需要对ajax请求返回的消息进行语音播报,str 为需要播报的信息(适应于错误信息语音提示等场景):

//语音播报
function voiceAnnouncements(str){
    // 百度语音合成:或者使用新版地址https://tsn.baidu.com/text2audio
    var url = "http://tts.baidu.com/text2audio?lan=zh&ie=UTF-8&spd=5&text=" + encodeURI(str);
    var n = new Audio(url);
    n.src = url;
    n.play();
};
voiceAnnouncements(`
秋名山上路人稀,常有车手较高低;
如今车道依旧在,不见当年老司机。
司机车技今尚好,前端群里不寂寥;
向天再借五百年,誓言各行领风骚。
`);
// 尝试了一些换女声的方式,但是,都失败了。。。
voiceAnnouncements(`
哇,代码写的真棒,你可真秀哇!
`);
  • 参数解释:

    • lan:固定值zh。语言选择,目前只有中英文混合模式,填写固定值zh
    • ie:编码方式
    • spd:语速,取值0-9,默认为5中语速
    • text:合成的文本,使用UTF-8编码。小于512个中文字或者英文数字。(文本在百度服务器内转换为GBK后,长度必须小于1024字节)
  • React Native Text-To-Speech library for Android and iOS
  • 用语音控制自己的网站 annyang:A tiny JavaScript Speech Recognition library that lets your users control your site with voice commands.annyang has no dependencies, weighs just 2 KB, and is free to use and modify under the MIT license。

第十三式:失焦事件与点击事件冲突怎么办?

  • 场景:

    • 下拉框中blur与click冲突;
    • 输入框blur与下方可点击浮沉click冲突:输入值时下方出现浮层,输入框失去焦点时,浮层隐藏;点击浮层条目触发搜索并隐藏浮层;
    • 问题:点击浮层时,由于失焦事件先触发,浮层隐藏逻辑执行,导致浮层的onClick事件逻辑无法执行

    失焦事件与点击事件冲突

// 点击弹窗条目进行搜索
handleSearch = (activeSearch) => {
  console.log(activeSearch);
  this.setState({ visible: false });
}

// 获得焦点,有值时展示弹窗
onFocus = () => {
  if (this.state.keyword) {
    this.setState({ visible: true });
  }
}

// 输入且有值时展示弹窗
onChange = (e) => {
  this.setState({
    keyword: e.target.value,
    visible: !!e.target.value
  })
}

// 失去焦点隐藏弹窗
onBlur = () => {
  if (this.state.keyword) {
    this.setState({ visible: false });
  }
}

render() {
  const { keyword, visible } = this.state;
  return (
    <div>
      <Input
        allowClear
        addonBefore={<Icon type="user" />}
        placeholder="支持ID、名称、主邮箱、客户经理、专属账户、客户ID、GroupID搜索"
        style={ { width: 460 } }
        onFocus={this.onFocus}
        onChange={this.onChange}
        onBlur={this.onBlur}
      />
      {
        // 展示弹窗(点击条目完成搜索)
        visible && keyword && <div className={styles.SearchSelect}>
          {
            showOptions.map(item => (
              <div
                onClick={() => this.handleSearch(item)}
                className={styles.item}
                key={item.key}
              >
                <div>
                  {item.label}:{keyword}
                </div>
              </div>
            ))
          }
        </div>
      }
    </div>
  );
}
  • 解决:

    • 方法一:给失焦事件设置延迟触发

        onBlur = () => {
          if (this.state.keyword) {
            setTimeout(() => {
              this.setState({ visible: false });
            }, 300);
          }
        }
    • 方法二:使用onMouseDown替代onClick

      • mousedown事件:当鼠标指针移动到元素上方,并按下鼠标按键时,会发生mousedown事件,所以它会先于失焦事件执行。
      • mouseup事件:当在元素上放松鼠标按钮时,会发生mouseup事件。

第十四式:不用加减乘除如何做加法——位运算让你的代码更高效

  • JavaScript 位运算符

    JavaScript 位运算符

  位运算是基于二进制的,如何快速获得二进制可参考第十式。

  • 不用加减乘除做加法

    function add(a,b) {
        let sum;
        let add1;
        while(b!=0) {
            // 异或
            sum = a^b;
            // 与 左移
            add1 = (a&b)<<1;
            a = sum;
            b = add1;
        }
        return a
    };
    add(1,2); // 3
  • JS按位运算符的妙用:

    • 使用&运算符判断一个数的奇偶(只需记住0和1与1进行&运算的结果即可):

      • 偶数 & 1 = 0
      • 奇数 & 1 = 1
    • 使用~~,>>,<<,>>>,|来取整:

      • ~~Math.PI:3(按位取反再取反)
      • Math.PI>>0Math.PI<<0Math.PI>>>0:3(按位左移或者右移0位,>>>不可用于负数)
      • Math.PI|0:3,按位异或
    • 使用<<,>>来计算乘除:

      • 整数左移n位相当于乘2的n次方;
      • 右移相当于除以2的n次方,再向下取整
    • 利用^来完成比较两个数是否相等:!(a ^ b)
    • 使用^来完成值交换:参考第十五式
    • 使用&,>>,|来完成rgb值和16进制颜色值之间的转换

      • 16进制颜色值转RGB:

        function hexToRGB(hex){
          hex = hex.replace("#","0x");
          let r = hex >> 16;
          let g = hex >> 8 & 0xff;
          let b = hex & 0xff;
          return "rgb("+r+","+g+","+b+")";
        };
        hexToRGB('#cccccc'); // rgb(204,204,204)
      • RGB转16进制颜色值:

        function RGBToHex(rgb){
          let rgbArr = rgb.split(/[^\d]+/);
          let color = rgbArr[1]<<16 | rgbArr[2]<<8 | rgbArr[3];
          return "#"+color.toString(16);
        };
        RGBToHex('rgb(204,204,204)'); // #cccccc
参考资料:JavaScript 位运算符

第十五式:无聊的脑筋急转弯,不借助第三个变量交换a,b两个变量值的N种方法

  • 方法一:加减

      a = a + b;
      b = a - b;
      a= a - b;
  • 方法二:位运算

      a ^= b;
      b ^= a;
      a ^= b;
  • 方法三:对象或者数组

      a = {a, b};
      b = a.a;
      a = a.b;
      // a = [a, b];
      // b = a[0];
      // a = a[1];
  • 方法四:ES 6 解构赋值

      [a, b] = [b, a]
  • 方案五:运算符优先级

      a = [b, b=a][0];
    参考资料: 不借助第三个变量交换a,b两个变量值

第十六式:如何在浏览器当前页面打开并操作另一个tab页

  if (window.customeWindow) {
    window.customeWindow.close()
  }
  window.customeWindow = window.open()
  window.customeWindow.document.write('<p style="color:red">写点什么呢?<p>')
  window.customeWindow.document.write('<p style="color:#cccccc">想写什么就写什么。<p>')
  window.customeWindow.document.write('再追加点别的。')
  window.customeWindow.document.close() // 连续追加输入结束
  window.customeWindow.document.write('哈哈,现在页面上就只有我了!')
  window.customeWindow.document.write('<p style="color:red">不,还有我!<p>')
参考资料:BRAFT EDITOR富文本编辑器预览

第十七式:产品说要按照中文拼音顺序排序?

  • 使用 stringObject.localeCompare(target) 方法实现中文按照拼音顺序排序
var array = ["上海", "北京", "杭州", "广东", "深圳", "西安"];
// localeCompare() 方法返回一个数字来指示一个参考字符串是否在排序顺序前面或之后或与给定字符串相同。
array = array.sort((item1, item2) => item1.localeCompare(item2));
// ["北京", "广东", "杭州", "上海", "深圳", "西安"]
参考资料:String.prototype.localeCompare()
  • 一个对象数组按照另一个数组排序
sortFunc = (propName, referArr) => (prev, next) => 
referArr.indexOf(prev[propName]) - referArr.indexOf(next[propName])
// 按照年龄age的顺序给person排序
const age = [33, 11, 55, 22, 66];
const person = [
 {age: 55, weight: 50},
 {age: 22, weight: 42},
 {age: 11, weight: 15},
 {age: 66, weight: 56},
 {age: 33, weight: 68}]
person.sort(sortFunc('age', age));
// 结果:
// [
//  {"age": 33,"weight": 68},
//  {"age": 11,"weight": 15},
//  {"age": 55,"weight": 50},
//  {"age": 22,"weight": 42},
//  {"age": 66,"weight": 56}
// ]

第十八式:这段代码为什么会报错,说好的分号可以省略呢?

  console.log(123)
  [12,2].filter(item => item > 3)
  // Uncaught TypeError: Cannot read property '2' of undefined
  // at <anonymous>:2:1
  • 分号推断:编译原理里的分号推断,作用是在编程的时候,让程序员省略掉不必要的分号;
  • JavaScript有着自动分号插入的机制(Automatic Semicolon Insertion),简称ASI(ASI 只是表示编译器正确理解了程序员的意图,并没有真的插入分号);
  • 浏览器引擎的 Parser(负责将JS 源码转换为 AST)总是优先将换行符前后的符号流当作一条语句解析(带换行的多行注释与换行符是等效的);
  • 所以在 Parser 眼里,以上代码是这样的:

    • console.log(123)[12,2].filter(item => item > 3)console.log(123)没有返回值,既undefined
    • [12,2]中的方括号被视为读取console.log(123)返回值中的属性2,类似于根据下标取数组中的元素;
    • 为什么是取属性2呢,因为12,2是个逗号表达式,表达式的值是最右边的“2”,如此以来,上面的报错信息就很好理解了。
  • 不能省略的分号:

    • for 循环头部的分号
    • 作为空语句存在的分号
    • [、(、+、-、和/五个字符开头的语句之前的分号
    资料参考:备胎的自我修养——趣谈 JavaScript 中的 ASI (Automatic Semicolon Insertion)

本文首发于个人博客,欢迎指正和star

查看原文

赞 159 收藏 109 评论 23

独钓寒江雪 赞了文章 · 2020-11-21

备胎的自我修养——趣谈 JavaScript 中的 ASI (Automatic Semicolon Insertion)

图片描述

什么是 ASI ?

自动分号插入 (automatic semicolon insertion, ASI) 是一种程序解析技术,它在 JavaScript 程序的语法分析 (parsing) 阶段起作用。

根据 ES2015 规范,某些(不是全部) JavaScript 语句需要用 ; 来表示语句的结束;然而为了方便书写,在某些情况下这些分号是可以从源码中省略的,此时我们称 ; 被 parser 自动插入到符号流 (token stream) 中,这种机制称为 ASI。

所谓的“自动分号插入”其实只是一种形象的描述,parser 并没有真的插入一个个分号。ASI 只是表示编译器正确理解了程序员的意图。听起来就像编译器对程序员说:“Hey,哥们!虽然这里你没写分号,但我知道你想说这条语句已经结束了。”

需要用 ; 来表示结束的语句是:

  • 空语句
  • letconstimportexport 开头的声明语句
  • var 开头的变量声明语句
  • 表达式语句
  • debugger 语句
  • continue 语句
  • break 语句
  • return 语句
  • throw 语句

并不是所有的 JavaScript 语句都需要用 ; 表示结束,例如:

  • 块语句
  • if 语句
  • try 语句

这些语句本来就不需要 ; 表示结束。

举例来说,函数声明 (Function declaration, FD) 不需要以分号结束:

function add10(num) {
    return num + 10;
} // I don't need a semicolon here

如果你画蛇添足地在 FD 之后写了一个 ;,它会被解析为一条空语句。

ASI 规则

ASI 是备胎(第二选择)

编译器不会优先启用 ASI 机制。实际上,在遇到行结束符 (Line Terminator) 时,编译器总是先试图将行结束符分隔的符号流当作一条语句来解析(其实有少数几个特例:returnthrowbreakcontinueyield++--,随后会介绍),实在不符合正确语法的情况下,才会退而求其次,启用 ASI 机制,将行结束符分隔的符号流当作两条语句(俗称,插入分号)。来看下面的例子:

var a = 0
var b = 1

这个简单代码段的符号流为:

var   a   =   0   \n   var   b   =   1

parser 从左至右解析这个符号流,解析过程中它遇到了换行符 LF ( \n , 行结束符之一)。它看起来这样自言自语:“我遇到了一个换行符,让我先试试去掉它,把这个代码段当作一条语句试试!”

于是 parser 实际上先解析了这样一条语句:

var a = 0 var b = 1
// Uncaught SyntaxError: Unexpected token var

很显然这是一条有语法错误的语句,此路不通!

parser 说:“这个符号流如果当作一条语句的话,是有语法错误的!这该怎么办呢?我是不是要就此放弃、直接抛出语法错误呢?不!我可是要成为海贼王的男人!我要启用 ASI 机制试试。”

于是不折不挠的 parser 又解析了下面的语句:

var a = 0; var b = 1     // legal syntax

Bingo! 没有 SyntaxError ,解析通过!

parser 于是得意地对程序员说:“Hey,哥们!虽然在 \n 前面你没写分号,但我知道你想说 var a = 0 这条赋值语句已经结束了!”

“高!实在是高!”

脆弱的符号、被误解的源码

需要注意的是,parser 对符号流的这种处理机制有时会导致它误解程序员的意图。

var a = [1, [2, 3]]
[3, 2, 1].map(function(num) {
    return num * num;
})

由于 parser 总是优先将换行符前后的符号流当作一条语句解析,parser 实际上先解析了下面的语句:

var a = [1, [2, 3]][3, 2, 1].map(function(num) {
    return num * num;
})

这是一条语法正确的语句。它的含义是:先声明变量a ,对 [1, [2, 3]][3, 2, 1] 求值之后得到数组 [2, 3] ,对 [2, 3] 进行 (num) => num * num 映射操作得到 [4, 9],将数组 [4, 9] 赋给变量 a

( 开始的语句,比如 IIFE ,也会导致程序被 parser 误解。

(function fn() {
    return fn;
})()
(function() {
    console.log('我会显示在控制台吗?');
})()

它等价于

// 一条函数连续调用语句
(function fn() {
    return fn;
})()(function() {
    console.log('我会显示在控制台吗?');
})()  // => fn

/ 开始的语句,通常是正则表达式放在语句起始处(这种情况比较少见),也会导致程序被 parser 误解。

var a = 2
/error/i.test('error')

它等价于

var a = 2 / error / i.test('error')
// => Uncaught ReferenceError: error is not defined

需要注意的是,虽然 var a = 2 / error / i.test('error') 会抛出 ReferenceError 异常,但它是一条没有语法错误 (SyntaxError) 的语句。换句话说,该语句在 parser 眼里是一条语法正确的语句,因此 parser 不会启用 ASI 机制。

语句起始处的 +- 也会导致源码被误解(更加少见)。

var num = 5
+new Date - new Date(2009, 10)

等价于

var num = 5 + new Date - new Date(2009, 10)

源码的意图被 parser 误解,有两个必要条件:

  1. parser 优先将行结束符前后的符号流按一条语句解析,这是 ECMAScript 标准的规定,所有 parser 必须要按此要求实现。
  2. 行结束符之后的符号 (token) 有二义性,使得该符号与上条语句能够无缝对接,不导致语法错误。

实际上,有二义性的符号本来就不多,能导致源码意图被改变的符号数来数去就只有 [(/+- 这五个而已。我们可以把它们理解成“脆弱的符号”,在它们前面显式地加上防御性分号 (defensive semicolon) 来保护其含义不被改变。

限制产生式——备胎转正

前文说到,ASI 是一种备用选择。然而在 ECMAScript 中,有几种特殊语句是不允许行结束符存在的。如果语句中有行结束符,parser 会优先认为行结束符表示的是语句的结束,这在 ECMAScript 标准中称为限制产生式 (restricted production)

通俗地说,在限制产生式中,parser 优先启用 ASI 机制。

一个典型限制产生式的例子是 return 语句。

function a() {
    return
    {};
}
a()   // => undefined

按照一般解析规则,如果 ASI 是第二选择,那么 parser 优先忽略 \n ,该代码段应与下面的程序无异:

function a() {
    return {};
}
a()  // => {} (empty object)

然而事实并非如此,因为 ECMAScript 标准对合法的 return 语句做了如下限制:

ReturnStatement:
return [no LineTerminator here] Expression ;

return 语句中是不允许在 return 关键字之后出现行结束符的,所以上面的代码段其实等价于:

function a() {
    return;    // ReturnStatement
    {}         // BlockStatement
    ;          // EmptyStatement
}
a()  // => undefined

函数体内的代码被解析为 return 语句、块语句、空语句三条单独的语句。

标准规定的其它限制产生式有:

  • continue 语句
  • break 语句
  • throw 语句
  • 箭头函数 (箭头左侧不允许有行结束符)
  • yield 表达式
  • 后自增/自减表达式

这些情况都不允许有换行符存在。

a
++
b

被解析为

a;
++b;

ES2015 标准给出了关于限制产生式的编程建议:

  • A postfix ++ or -- operator should appear on the same line as its operand. (后自增运算符或后自减运算符应与它的操作数处于同一行。)
  • An Expression in a return or throw statement or an AssignmentExpression in a yield expression should start on the same line as the return, throw, or yield token. (returnthrow 语句中的表达式以及 yield 表达式中的赋值表达式应与 returnthrowyield 这些关键字处于同一行。)
  • An IdentifierReference in a break or continue statement should be on the same line as the break or continue token. (breakcontinue 语句中的标签名应与 breakcontinue 关键字处于同一行。)

言而总之,总而言之,ES2015 标准这一节就告诉你一件事:在限制生产式中别换行,换行就自动插入分号。

for 循环与空语句——永不使用的备胎

ASI 不适用于 for 循环头部,即 parser 不会在这里自动插入分号。

var a = ['once', 'a', 'rebound,', 'always', 'a', 'rebound.']
var msg = ''
for (var i = 0, len = a.length
    i < len
    i++) {
    msg += a[i] + ' '
}
console.log(msg)

好吧,也许你希望 parser 在 a.length 后面和 i < len 后面自动为你插入分号,补全这个 for 循环语句,但是 parser 不会在 for 循环的头部启用 ASI 机制。parser 首先尝试按一条语句解析

(var i = 0, len = a.length \n i < len \n i++)

这个符号流,发现它并非一条合法语句后,就直接抛出了语法错误 Uncaught SyntaxError: Unexpected Identifier,根本不尝试补全分号。

所以 for 循环头部的分号必须要显式地写出:

var a = ['once', 'a', 'rebound,', 'always', 'a', 'rebound.']
var msg = ''
for (var i = 0, len = a.length;
i < len;
i++) {
msg += a[i] + ' '
}
console.log(msg)
// => 'once a rebound, always a rebound.' (一朝为备胎,永久为备胎)

类似地,有特殊含义的空语句也不可以省略分号:

function infiniteLoop() {
    while('a rebound is a rebound is a rebound')
}

此段代码是不合法的语句,parser 会抛出语法错误 Uncaught SyntaxError: Unexpected token } 。这是因为循环体中作为空语句而存在的 ; 不能省略。

// legal syntax
function infiniteLoop() {
    while('a rebound is a rebound is a rebound');
}
// it is true that a rebound is a rebound is a rebound (备胎就是备胎,这是真理。)

总结

ASI 机制的存在为 JavaScript 程序员提供了一种选择:你可以省略源码中的绝大部分 ; 而不影响程序的正确解析。与 IT 业界的 “Vim 和 Emacs 哪个是更好的编辑器” 一样,JavaScript 社区隔一段时间就会出现“该不该写分号”这样的观点之争。本文并不是想证明哪种观点更好,而是关注 ASI 机制本身的一些有趣事实。即便是坚定的无分号党,也不得不承认,有些分号是不能省略的。这些不能省略的分号有:

  • for 循环头部的分号
  • 作为空语句存在的分号
  • 以 5 个“脆弱符号”开头的语句之前的分号 (严格来讲,此处的分号不是必须的;因为除了使用分号,还可以用各种 hack 方法,比如 void)

而对于坚定的分号党,有一个事实也不得不承认,那就是你的程序中很可能有 99% 的分号都是多余的!如果你想尝试一下不写分号,可以按照下面的步骤:

  1. 删掉你所有语句结尾处的分号
  2. 如果你的语句开头是 [(,在它前面加一个分号。Over!

相关资料

  1. Effective JavaScript: 68 Specific Ways to Harness the Power of JavaScript. Item. 6
  2. ES2015 Spec Section 11.9.1: Rules of Automatic Semicolon Insertion
  3. An Open Letters to JavaScript Leaders Regarding Semicolons
  4. JavaScript Semicolon Insertion. Everything you need to know
  5. A Bit of Advice for the JavaScript Semicolon Haters
  6. Automatic semicolon insertion in JavaScript
查看原文

赞 15 收藏 28 评论 1

独钓寒江雪 赞了文章 · 2020-11-10

小蝌蚪传记:让接口提速60%的优化技巧

FFCreator是我们团队做的一个轻量、灵活的短视频加工库。您只需要添加几张图片或文字,就可以快速生成一个类似抖音的酷炫短视频。github地址:https://github.com/tnfe/FFCreator 欢迎小伙伴star。

背景

好久没写文章了,沉寂了大半年

持续性萎靡不振,间歇性癫痫发作

天天来大姨爹,在迷茫、焦虑中度过每一天

不得不承认,其实自己就是个废物

作为一名低级前端工程师

最近处理了一个十几年的祖传老接口

它继承了一切至尊级复杂度逻辑

传说中调用一次就能让cpu负载飙升90%的日天服务

专治各种不服与老年痴呆

我们欣赏一下这个接口的耗时

平均调用时间在3s以上

导致页面出现严重的转菊花

经过各种深度剖析与专业人士答疑

最后得出结论是:放弃医疗

鲁迅在《狂人日记》里曾说过:“能打败我的,只有女人和酒精,而不是bug

每当身处黑暗之时

这句话总能让我看到光

所以这次要硬起来

我决定做一个node代理层

用下面三个方法进行优化:

  • 按需加载 -> graphQL
  • 数据缓存 -> redis
  • 轮询更新 -> schedule

代码地址:github

按需加载 -> graphQL

天秀老接口存在一个问题,我们每次请求1000条数据,返回的数组中,每一条数据都有上百个字段,其实我们前端只用到其中的10个字段而已。

如何从一百多个字段中,抽取任意n个字段,这就用到graphQL。

graphQL按需加载数据只需要三步:

  • 定义数据池 root
  • 描述数据池中数据结构 schema
  • 自定义查询数据 query

定义数据池

我们针对屌丝追求女神的场景,定义一个数据池,如下:

// 数据池
var root = {
    girls: [{
        id: 1,
        name: '女神一',
        iphone: 12345678910,
        weixin: 'xixixixi',
        height: 175,
        school: '剑桥大学',
        wheel: [{ name: '备胎1号', money: '24万元' }, { name: '备胎2号', money: '26万元' }]
    },
    {
        id: 2,
        name: '女神二',
        iphone: 12345678910,
        weixin: 'hahahahah',
        height: 168,
        school: '哈佛大学',
        wheel: [{ name: '备胎3号', money: '80万元' }, { name: '备胎4号', money: '200万元' }]
    }]
}

里面有两个女神的所有信息,包括女神的名字、手机、微信、身高、学校、备胎集合等信息。

接下来我们就要对这些数据结构进行描述。

描述数据池中数据结构

const { buildSchema } = require('graphql');

// 描述数据结构 schema
var schema = buildSchema(`
    type Wheel {
        name: String,
        money: String
    }
    type Info {
        id: Int
        name: String
        iphone: Int
        weixin: String
        height: Int
        school: String
        wheel: [Wheel]
    }
    type Query {
        girls: [Info]
    }
`);

上面这段代码就是女神信息的schema。

首先我们用type Query定义了一个对女神信息的查询,里面包含了很多女孩girls的信息Info,这些信息是一堆数组,所以是[Info]

我们在type Info中描述了一个女孩的所有信息的维度,包括了名字(name)、手机(iphone)、微信(weixin)、身高(height)、学校(school)、备胎集合(wheel)

定义查询规则

得到女神的信息描述(schema)后,就可以自定义获取女神的各种信息组合了。

比如我想和女神认识,只需要拿到她的名字(name)和微信号(weixin)。查询规则代码如下:

const { graphql } = require('graphql');

// 定义查询内容
const query = `
    { 
        girls {
            name
            weixin
        }
    }
`;

// 查询数据
const result = await graphql(schema, query, root)

筛选结果如下:

又比如我想进一步和女神发展,我需要拿到她备胎信息,查询一下她备胎们(wheel)的家产(money)分别是多少,分析一下自己能不能获取优先择偶权。查询规则代码如下:

const { graphql } = require('graphql');

// 定义查询内容
const query = `
    { 
        girls {
            name
            wheel {
                money
            }
        }
    }
`;

// 查询数据
const result = await graphql(schema, query, root)

筛选结果如下:

我们通过女神的例子,展现了如何通过graphQL按需加载数据。

映射到我们业务具体场景中,天秀接口返回的每条数据都包含100个字段,我们配置schema,获取其中的10个字段,这样就避免了剩下90个不必要字段的传输。

graphQL还有另一个好处就是可以灵活配置,这个接口需要10个字段,另一个接口要5个字段,第n个接口需要另外x个字段

按照传统的做法我们要做出n个接口才能满足,现在只需要一个接口配置不同schema就能满足所有情况了。

感悟

在生活中,咱们舔狗真的很缺少graphQL按需加载的思维

渣男渣女,各取所需

你的真情在名媛面前不值一提

我们要学会投其所好

上来就亮车钥匙,没有车就秀才艺

今晚我有一条祖传的染色体想与您分享一下

行就行,不行就换下一个

直奔主题,简单粗暴

缓存 -> redis

第二个优化手段,使用redis缓存

天秀老接口内部调用了另外三个老接口,而且是串行调用,极其耗时耗资源,秀到你头皮发麻

我们用redis来缓存天秀接口的聚合数据,下次再调用天秀接口,直接从缓存中获取数据即可,避免高耗时的复杂调用,简化后代码如下:

const redis = require("redis");
const { promisify } = require("util");

// 链接redis服务
const client = redis.createClient(6379, '127.0.0.1');

// promise化redis方法,以便用async/await
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);

async function list() {
    // 先获取缓存中数据,没有缓存就去拉取天秀接口
    let result = await getAsync("缓存");
    if (!result) {
          // 拉接口
          const data = await 天秀接口();
          result = data;
          // 设置缓存数据
          await setAsync("缓存", data)
    }
       return result;
}

list(); 

先通过getAsync来读取redis缓存中的数据,如果有数据,直接返回,绕过接口调用,如果没有数据,就会调用天秀接口,然后setAsync更新到缓存中,以便下次调用。因为redis存储的是字符串,所以在设置缓存的时候,需要加上JSON.stringify(data),为了便于大家理解,我就不加了,会把具体细节代码放在github中。

将数据放在redis缓存里有几个好处

可以实现多接口复用、多机共享缓存

这就是传说中的云备胎

追求一个女神的成功率是1%

同时追求100个女神,那你获取到一个女神的概率就是100%

鲁迅《狂人日记》里曾说过:“舔一个是舔狗,舔一百个你就是战狼

你是想当舔狗还是当战狼?

来吧,缓存用起来,redis用起来

轮询更新 -> schedule

最后一个优化手段:轮询更新 -> schedule

女神的备胎用久了,会定时换一批备胎,让新鲜血液进来,发现新的快乐

缓存也一样,需要定时更新,保持与数据源的一致性,代码如下:

const schedule = require('node-schedule');

// 每个小时更新一次缓存
schedule.scheduleJob('* * 0 * * *', async () => {
    const data = await 天秀接口();
    // 设置redis缓存数据
    await setAsync("缓存", data)
});

我们用node-schedule这个库来轮询更新缓存,* * 0 * * *这个的意思就是设置每个小时的第0分钟就开始执行缓存更新逻辑,将获取到的数据更新到缓存中,这样其他接口和机器在调用缓存的时候,就能获取到最新数据,这就是共享缓存和轮询更新的好处。

早年我在当舔狗的时候,就将轮询机制发挥到淋漓尽致

每天向白名单里的女神,定时轮询发消息

无限循环云跪舔三件套:

  • “啊宝贝,最近有没有想我”
  • “啊宝贝早安安”
  • “宝贝晚安,么么哒”

虽然女神依然看不上我

但仍然时刻准备着为女神服务!

结尾

经过以上三个方法优化后

接口请求耗时从3s降到了860ms

这些代码都是从业务中简化后的逻辑

真实的业务场景远比这要复杂:分段式数据存储、主从同步 读写分离、高并发同步策略等等

每一个模块都晦涩难懂

就好像每一个女神都高不可攀

屌丝战胜了所有bug,唯独战胜不了她的心

受伤了只能在深夜里独自买醉

但每当梦到女神打开我做的页面

被极致流畅的体验惊艳到

在精神高潮中享受灵魂升华

那一刻

我觉得我又行了

(完)

代码地址:github

作者:第一名的小蝌蚪,公众号:前端屌丝
查看原文

赞 19 收藏 7 评论 6

独钓寒江雪 赞了文章 · 2020-10-28

使用Node.js原生API写一个web服务器

Node.jsJavaScript基础上发展起来的语言,所以前端开发者应该天生就会一点。一般我们会用它来做CLI工具或者Web服务器,做Web服务器也有很多成熟的框架,比如ExpressKoa。但是ExpressKoa都是对Node.js原生API的封装,所以其实不借助任何框架,只用原生API我们也能写一个Web服务器出来。本文要讲的就是不借助框架,只用原生API怎么写一个Web服务器。因为在我的计划中,后面会写ExpressKoa的源码解析,他们都是使用原生API来实现的。所以本文其实是这两个源码解析的前置知识,可以帮我们更好的理解ExpressKoa这种框架的意义和源码。本文仅为说明原生API的使用方法,代码较丑,请不要在实际工作中模仿!

本文可运行代码示例已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/HttpServer

Hello World

要搭建一个简单的Web服务器,使用原生的http模块就够了,一个简单的Hello World程序几行代码就够了:

const http = require('http')

const port = 3000

const server = http.createServer((req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/plain')
  res.end('Hello World')
})

server.listen(port, () => {
  console.log(`Server is running on http://127.0.0.1:${port}/`)
})

这个例子就很简单,直接用http.createServer创建了一个服务器,这个服务器也没啥逻辑,只是在访问的时候返回Hello World。服务器创建后,使用server.listen运行在3000端口就行。

这个例子确实简单,但是他貌似除了输出一个Hello World之外,啥也干不了,离我们一般使用的Web服务器还差了很远,主要是差了这几块:

  1. 不支持HTTP动词,比如GETPOST
  2. 不支持路由
  3. 没有静态资源托管
  4. 不能持久化数据

前面三点是一个Web服务器必备的基础功能,第四点是否需要要看情况,毕竟目前很多NodeWeb服务器只是作为一个中间层,真正跟数据库打交道做持久化的还是各种微服务,但是我们也应该知道持久化怎么做。

所以下面我们来写一个真正能用的Web服务器,也就是说把前面缺的几点都补上。

处理路由和HTTP动词

前面我们的那个Hello World也不是完全不能用,因为代码位置还是得在http.createServer里面,我们就在里面添加路由的功能。为了跟后面的静态资源做区分,我们的API请求都以/api开头。要做路由匹配也不难,最简单的就是直接用if条件判断就行。为了能拿到请求地址,我们需要使用url模块来解析传过来的地址。而Http动词直接可以用req.method拿到。所以http.createServer改造如下:

const url = require('url');

const server = http.createServer((req, res) => {
  // 获取url的各个部分
  // url.parse可以将req.url解析成一个对象
  // 里面包含有pathname和querystring等
  const urlObject = url.parse(req.url);
  const { pathname } = urlObject;

  // api开头的是API请求
  if (pathname.startsWith('/api')) {
    // 再判断路由
    if (pathname === '/api/users') {
      // 获取HTTP动词
      const method = req.method;
      if (method === 'GET') {
        // 写一个假数据
        const resData = [
          {
            id: 1,
            name: '小明',
            age: 18
          },
          {
            id: 2,
            name: '小红',
            age: 19
          }
        ];
        res.setHeader('Content-Type', 'application/json')
        res.end(JSON.stringify(resData));
        return;
      }
    }
  }
});

现在我们访问/api/users就可以拿到用户列表了:

image.png

支持静态文件

上面说了API请求是以/api开头,也就是说不是以这个开头的可以认为都是静态文件,不同文件有不同的Content-Type,我们这个例子里面暂时只支持一种.jpg吧。其实就是给我们的if (pathname.startsWith('/api'))加一个else就行。返回静态文件需要:

  1. 使用fs模块读取文件。
  2. 返回文件的时候根据不同的文件类型设置不同的Content-Type

所以我们这个else就长这个样子:

// ... 省略前后代码 ...

else {
  // 使用path模块获取文件后缀名
  const extName = path.extname(pathname);

  if (extName === '.jpg') {
    // 使用fs模块读取文件
    fs.readFile(pathname, (err, data) => {
      res.setHeader('Content-Type', 'image/jpeg');
      res.write(data);
      res.end();
    })
  }
}

然后我们在同级目录下放一个图片试一下:

image.png

数据持久化

数据持久化的方式有好几种,一般都是存数据库,少数情况下也有存文件的。存数据库比较麻烦,还需要创建和连接数据库,我们这里不好demo,我们这里演示一个存文件的例子。一般POST请求是用来存新数据的,我们在前面的基础上再添加一个POST /api/users来新增一条数据,只需要在前面的if (method === 'GET')后面加一个POST的判断就行:

// ... 省略其他代码 ...

else if (method === 'POST') {
  // 注意数据传过来可能有多个chunk
  // 我们需要拼接这些chunk
  let postData = '';
  req.on('data', chunk => {
    postData = postData + chunk;
  })

  req.on('end', () => {
    // 数据传完后往db.txt插入内容
    fs.appendFile(path.join(__dirname, 'db.txt'), postData, () => {
      res.end(postData);  // 数据写完后将数据再次返回
    });
  })
}

然后我们测试一下这个API:

image-20201007165330636

再去看看文件里面写进去没有:

image-20201007165506756

总结

到这里我们就完成了一个具有基本功能的web服务器,代码不复杂,但是对于帮我们理解Node web服务器的原理很有帮助。但是上述代码还有个很大的问题就是:代码很丑!所有代码都写在一堆,而且HTTP动词和路由匹配全部是使用if条件判断,如果有几百个API,再配合十来个动词,那代码简直就是个灾难!所以我们应该将路由处理HTTP动词静态文件数据持久化这些功能全部抽离出来,让整个应用变得更优雅,更好扩展。这就是ExpressKoa这些框架存在的意义,下一篇文章我们就去Express的源码看看他是怎么解决这个问题的,点个关注不迷路~

本文可运行代码示例已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/HttpServer

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 42 收藏 32 评论 0

独钓寒江雪 关注了用户 · 2020-10-16

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2146