bt365体育投注.主頁欢迎您!!

    <acronym id="zvmrr"></acronym>
    <td id="zvmrr"></td>
  • <tr id="zvmrr"><label id="zvmrr"></label></tr>
  • <acronym id="zvmrr"></acronym>
  • 独钓寒江雪

    独钓寒江雪 查看完整档案

    上海编辑南京航空航天大学  |  信息与计算科学 编辑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

    查看原文

    赞 16 收藏 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和完善,大家面试可以参照考点复习,另外关注公众号,后台回复福利,即可看到福利,你懂的。

    查看原文

    赞 36 收藏 28 评论 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.;
    };
    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.;
    };
    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

    复合型

    复合型,对就是上面的几个类别中,随便挑几种组合到一起。

    我就不废话了~~。

    结论

    那么,我们应该用哪种微前端方案呢?答案见下一篇《微前端快速选型指南》

    相关资料:

    查看原文

    赞 151 收藏 187 评论 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上搭建了个人博客,这也算是个人在前端领域一次新的突破和尝试,希望能够贡献一些有用的东西来与大家交流。

    个人博客地址

    查看原文

    认证与成就

    • 获得 531 次点赞
    • 获得 13 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 11 枚铜徽章

    擅长技能
    编辑

    开源项目 & 著作
    编辑

    • 个人博客

      个人前端技术总结、分享、踩坑日志等

    注册于 2018-05-06
    个人主页被 9.1k 人浏览

    bt365体育投注