Quantcast
Channel: IT瘾javascript推荐
Viewing all 148 articles
Browse latest View live

Javascript 面试中经常被问到的三个问题!

$
0
0

图片描述

本文不是讨论最新的 JavaScript 库、常见的开发实践或任何新的 ES6 函数。相反,在讨论 JavaScript 时,面试中通常会提到三件事。我自己也被问到这些问题,我的朋友们告诉我他们也被问到这些问题。

然,这些并不是你在面试之前应该学习的唯一三件事 - 你可以通过 多种方式更好地为即将到来的面试做准备 - 但面试官可能会问到下面是三个问题,来判断你对 JavaScript语言的理解和 DOM的掌握程度。

让我们开始吧!注意,我们将在下面的示例中使用原生的 JavaScript,因为面试官通常希望了解你在没有 jQuery 等库的帮助下对JavaScript 和 DOM 的理解程度。

问题 1: 事件委托代理

在构建应用程序时,有时需要将事件绑定到页面上的按钮、文本或图像,以便在用户与元素交互时执行某些操作。

如果我们以一个简单的待办事项列表为例,面试官可能会告诉你,当用户点击列表中的一个列表项时执行某些操作。他们希望你用 JavaScript 实现这个功能,假设有如下 HTML 代码:

<ul id="todo-app"><li class="item">Walk the dog</li><li class="item">Pay bills</li><li class="item">Make dinner</li><li class="item">Code for one hour</li></ul>

你可能想要做如下操作来将事件绑定到元素:

document.addEventListener('DOMContentLoaded', function() {
  let app = document.getElementById('todo-app');
  let times = app.getElementsByClassName('item');

  for (let item of items) {
    item.addEventListener('click', function(){
      alert('you clicked on item: ' + item.innerHTML);
    })
  }
})

虽然这在技术上是可行的,但问题是要将事件分别绑定到每个项。这对于目前 4个元素来说,没什么大问题,但是如果在待办事项列表中添加了 10,000项(他们可能有很多事情要做)怎么办?然后,函数将创建 10,000 个独立的事件侦听器,并将每个事件监听器绑定到 DOM ,这样代码执行的效率非常低下。

在面试中,最好先问面试官用户可以输入的最大元素数量是多少。例如,如果它不超过 10,那么上面的代码就可以很好地工作。但是如果用户可以输入的条目数量没有限制,那么你应该使用一个更高效的解决方案。

如果你的应用程序最终可能有数百个事件侦听器,那么更有效的解决方案是将一个事件侦听器实际绑定到整个容器,然后在单击它时能够访问每个列表项, 这称为 事件委托,它比附加单独的事件处理程序更有效。

下面是事件委托的代码:

document.addEventListener('DOMContentLoaded', function() {
  let app = document.getElementById('todo-app');

  app.addEventListener('click', function(e) {
    if (e.target && e.target.nodeName === 'LI') {
      let item = e.target;
      alert('you clicked on item: ' + item.innerHTML)
    }
  })
})

问题 2: 在循环中使用闭包

闭包常常出现在面试中,以便面试官衡量你对 JS 的熟悉程度,以及你是否知道何时使用闭包。

闭包基本上是内部函数可以访问其范围之外的变量。 闭包可用于实现隐私和创建函数工厂, 闭包常见的面试题如下:

编写一个函数,该函数将遍历整数列表,并在延迟3秒后打印每个元素的索引。

经常不正确的写法是这样的:

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
  setTimeout(function() {
    console.log('The index of this number is: ' + i);
  }, 3000);
}

如果运行上面代码, 3秒延迟后你会看到,实际上每次打印输出是 4,而不是期望的 0,1,2,3

为了正确理解为什么会发生这种情况,了解为什么会在 JavaScript 中发生这种情况将非常有用,这正是面试官试图测试的内容。

原因是因为 setTimeout函数创建了一个可以访问其外部作用域的函数(闭包),该作用域是包含索引 i的循环。 经过 3秒后,执行该函数并打印出 i的值,该值在循环结束时为 4,因为它循环经过 0,1,2,3,4并且循环最终停止在 4

实际上有 多处方法来正确的解这道题:

const arr = [10, 12, 15, 21];

for (var i = 0; i < arr.length; i++) {
  setTimeout(function(i_local){
    return function () {
      console.log('The index of this number is: ' + i_local);
    }
  }(i), 3000)
}


const arr = [10, 12, 15, 21];
for (let i = 0; i < arr.length; i++) {
  setTimeout(function() {
    console.log('The index of this number is: ' + i);
  }, 3000);
}

问题 3:事件的节流(throttle)与防抖(debounce)

有些浏览器事件可以在短时间内快速触发多次,比如调整窗口大小或向下滚动页面。例如,监听页面窗口滚动事件,并且用户持续快速地向下滚动页面,那么滚动事件可能在 3 秒内触发数千次,这可能会导致一些严重的性能问题。

如果在面试中讨论构建应用程序,出现滚动、窗口大小调整或按下键等事件请务必提及 防抖(Debouncing)函数节流(Throttling)来提升页面速度和性能。这两兄弟的本质都是以 闭包的形式存在。通过对事件对应的回调函数进行包裹、以自由变量的形式缓存时间信息,最后用 setTimeout 来控制事件的触发频率。

Throttle: 第一个人说了算

throttle 的主要思想在于:在某段时间内,不管你触发了多少次回调,都只认第一次,并在计时结束时给予响应。

这个故事里,‘裁判’ 就是我们的节流阀, 他控制参赛者吃东西的时机, “参赛者吃东西”就是我们频繁操作事件而不断涌入的回调任务,它受 “裁判” 的控制,而计时器,就是上文提到的以自由变量形式存在的时间信息,它是 “裁判” 决定是否停止比赛的依据,最后,等待比赛结果就对应到回调函数的执行。

总结下来,所谓的“节流”,是通过在一段时间内无视后来产生的回调请求来实现的。只要 裁判宣布比赛开始,裁判就会开启计时器,在这段时间内,参赛者就尽管不断的吃,谁也无法知道最终结果。

对应到实际的交互上是一样一样的:每当用户触发了一次 scroll 事件,我们就为这个触发操作开启计时器。一段时间内,后续所有的 scroll 事件都会被当作“参赛者吃东西——它们无法触发新的 scroll 回调。直到“一段时间”到了,第一次触发的 scroll 事件对应的回调才会执行,而“一段时间内”触发的后续的 scroll 回调都会被节流阀无视掉。

现在一起实现一个 throttle:

// fn 是我们需要包装的事件回调, interval 是时间间隔的阈值
function throttle(fn, interval) {
  // last 为上一次触发回调时间
  let last = 0

  // 将 throttle 处理结果当然函数返回
  return function () {
    // 保留调用时的 this 上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments
    // 记录本次触发回调的时间
    let now = +new Date()

    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
    if (now - last >= interval) {
      // 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
      last = fn.apply(context, args)
    }
  }
}

// 用 throttle来包装 scroll 的回调
const better_scroll = throttle(() => { console.log('触发了滚动事件')}, 1000)
document.addEventListener('scroll', better_scroll)

Debounce: 最后一个参赛者说了算

防抖的主要思想在于:我会等你到底。在某段时间内,不管你触发了多少次回调,我都只认最后一次。

继续大胃王比赛故事,这次换了一种比赛方式,时间不限,参赛者吃到不能吃为止,当每个参赛都吃不下的时候,后面10分钟如果没有人在吃,比赛结束,如果有人在10分钟内还能吃,则比赛继续,直到下一次10分钟内无人在吃时为止。

对比 throttle 来理解 debounce: 在 throttle 的逻辑里, ‘裁判’ 说了算,当比赛时间到时,就执行回调函数。而 debounce 认为最后一个参赛者说了算,只要还能吃的,就重新设定新的定时器。

现在一起实现一个 debounce:

// fn 是我们需要包装事件回调,delay 是每次推迟执行的等待时间
function debounce(fn, delay) {
  // 定时器
  let timer = null

  // 将 debounce 处理结果当作函数返回
  return function () {
    // 保留调用时的 this 上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments

    // 每次事件被触发时,都去清除之前的旧定时器
    if (timer) {
      clearTimeout(timer)
    }
    // 设立新定时器
    timer = setTimeout(function() {
      fn.apply(context, args)
    }, delay)
  }
}

// 用 debounce 来包装 scroll 的回调
const better_scroll = debounce(() => { console.log('发了滚动事件')}, 1000)
document.addEventListener('scroll', better_scroll)

用 Throttle 来优化 Debounce

debounce 的问题在于它“太有耐心了”。试想,如果用户的操作十分频繁——他每次都不等 debounce 设置的 delay 时间结束就进行下一次操作,于是每次 debounce 都为该用户重新生成定时器,回调函数被延迟了不计其数次。频繁的延迟会导致用户迟迟得不到响应,用户同样会产生“这个页面卡死了”的观感。

为了避免弄巧成拙,我们需要借力 throttle 的思想,打造一个“有底线”的 debounce——等你可以,但我有我的原则:delay 时间内,我可以为你重新生成定时器;但只要delay的时间到了,我必须要给用户一个响应。这个 throttle 与 debounce “合体”思路,已经被很多成熟的前端库应用到了它们的加强版 throttle 函数的实现中:

// fn是我们需要包装的事件回调, delay是时间间隔的阈值
function throttle(fn, delay) {
  // last为上一次触发回调的时间, timer是定时器
  let last = 0, timer = null
  // 将throttle处理结果当作函数返回
  
  return function () { 
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments
    // 记录本次触发回调的时间
    let now = +new Date()
    
    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
    if (now - last < delay) {
    // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
       clearTimeout(timer)
       timer = setTimeout(function () {
          last = now
          fn.apply(context, args)
        }, delay)
    } else {
        // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
        last = now
        fn.apply(context, args)
    }
  }
}

// 用新的throttle包装scroll的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)

document.addEventListener('scroll', better_scroll)

参考:

Throttling and Debouncing in JavaScript
The Difference Between Throttling and Debouncing
Examples of Throttling and Debouncing
Remy Sharp’s blog post on Throttling function calls
前端性能优化原理与实践

原文:

https://medium.freecodecamp.o...

你的点赞是我持续分享好东西的动力,欢迎点赞!

一个笨笨的码农,我的世界只能终身学习!

更多内容请关注公众号 《大迁世界》


前端性能优化不完全手册

$
0
0

性能优化是一门大学问,本文仅对个人一些积累知识的阐述,欢迎下面补充。

抛出一个问题,从输入 url地址栏到所有内容显示到界面上做了哪些事?
  • 1.浏览器向 DNS 服务器请求解析该 URL 中的域名所对应的 IP地址;
  • 2.建立 TCP连接(三次握手);
  • 3.浏览器发出读取文件( URL中域名后面部分对应的文件)的 HTTP请求,该请求报文作为 TCP三次握手的第三个报文的数据发送给服务器;
  • 4.服务器对浏览器请求作出响应,并把对应的 html 文本发送给浏览器;
  • 5.浏览器将该 html文本并显示内容;
  • 6.释放 TCP连接(四次挥手);
上面这个问题是一个面试官非常喜欢问的问题,我们下面把这6个步骤分解,逐步细谈优化。

一、 DNS解析

  • DNS`解析:将域名解析为ip地址 ,由上往下匹配,只要命中便停止

    • 走缓存
    • 浏览器DNS缓存
    • 本机DNS缓存
    • 路由器DNS缓存
    • 网络运营商服务器DNS缓存 (80%的DNS解析在这完成的)
    • 递归查询
优化策略:尽量允许使用浏览器的缓存,能给我们节省大量时间。

二、 TCP的三次握手

  • SYN (同步序列编号)ACK(确认字符)

    • 第一次握手:Client将标志位SYN置为1,随机产生一个值seq=J,并将该数据包发送给Server,Client进入SYN_SENT状态,等 待Server确认。
    • 第二次握手:Server收到数据包后由标志位SYN=1知道Client请求建立连接,Server将标志位SYN和ACK都置为1,ack=J+1,随机产生一个值seq=K,并将该数据包发送给Client以确认连接请求,Server进入SYN_RCVD状态。
    • 第三次握手:Client收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给Server,Server检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,Client和Server进入ESTABLISHED状态,完成三次握手,随后Client与Server之间可以开始传输数据了。

三、浏览器发送请求

优化策略:
    • 1. HTTP协议通信最耗费时间的是建立 TCP连接的过程,那我们就可以使用 HTTP Keep-Alive,在 HTTP 早期,每个 HTTP 请求都要求打开一个 TCP socket连接,并且使用一次之后就断开这个 TCP连接。 使用 keep-alive可以改善这种状态,即在一次TCP连接中可以持续发送多份数据而不会断开连接。通过使用 keep-alive机制,可以减少 TCP连接建立次数,也意味着可以减少 TIME_WAIT状态连接,以此提高性能和提高 http服务器的吞吐率(更少的 tcp连接意味着更少的系统内核调用
    • 2.但是, keep-alive并不是免费的午餐,长时间的 TCP连接容易导致系统资源无效占用。配置不当的 keep-alive,有时比重复利用连接带来的损失还更大。所以,正确地设置 keep-alive timeout时间非常重要。(这个 keep-alive_timout时间值意味着:一个 http产生的 tcp连接在传送完最后一个响应后,还需要 holdkeepalive_timeout秒后,才开始关闭这个连接),如果想更详细了解可以看这篇文章 keep-alve性能优化的测试结果
    • 3.使用 webScoket通信协议,仅一次 TCP握手就一直保持连接,而且他对二进制数据的传输有更好的支持,可以应用于即时通信,海量高并发场景。 webSocket的原理以及详解
    • 4.减少 HTTP请求次数,每次 HTTP请求都会有请求头,返回响应都会有响应头,多次请求不仅浪费时间而且会让网络传输很多无效的资源,使用前端模块化技术 AMD CMD commonJS ES6等模块化方案将多个文件压缩打包成一个,当然也不能都放在一个文件中,因为这样传输起来可能会很慢,权衡取一个中间值
    • 5.配置使用懒加载,对于一些用户不立刻使用到的文件到特定的事件触发再请求,也许用户只是想看到你首页上半屏的内容,但是你却请求了整个页面的所有图片,如果用户量很大,那么这是一种极大的浪费
    • 6.服务器资源的部署尽量使用同源策略

    四、服务器返回响应,浏览器接受到响应数据

    五、浏览器解析数据,绘制渲染页面的过程

    • 先预解析(将需要发送请求的标签的请求发出去)
    • 从上到下解析 html文件
    • 遇到HTML标签,调用html解析器将其解析 DOM
    • 遇到 css标记,调用css解析器将其解析 CSSOM
    • link阻塞 - 为了解决闪屏,所有解决闪屏的样式
    • style非阻塞,与闪屏的样式不相关的
    • DOM树和 CSSOM树结合在一起,形成 render
    • layout布局 render渲染
    • 遇到 script标签,阻塞,调用 js解析器解析 js代码,可能会修改 DOM树,也可能会修改 CSSOM
    • DOM树和 CSSOM树结合在一起,形成 render
    • layout布局 render渲染(重排重绘)
    • script标签的属性

      • async 异步 谁先回来谁就先解析,不阻塞
      • defer 异步 按照先后顺序(defer)解析,不阻塞
      • script标签放在body下,放置多次重排重绘,能够操作dom
    性能优化策略:
    • 需要阻塞的样式使用 link引入,不需要的使用 style标签(具体是否需要阻塞看业务场景)
    • 图片比较多的时候,一定要使用懒加载,图片是最需要优化的, webpack4中也要配置图片压缩,能极大压缩图片大小,对于新版本浏览器可以使用 webp格式图片webP详解,图片优化对性能提升最大。
    • webpack4配置 代码分割,提取公共代码成单独模块。方便缓存
        /*
        runtimeChunk 设置为 true, webpack 就会把 chunk 文件名全部存到一个单独的 chunk 中,
        这样更新一个文件只会影响到它所在的 chunk 和 runtimeChunk,避免了引用这个 chunk 的文件也发生改变。
        */
        runtimeChunk: true, 
        splitChunks: {
          chunks: 'all'  // 默认 entry 的 chunk 不会被拆分, 配置成 all, 就可以了
        }
      }
        //因为是单入口文件配置,所以没有考虑多入口的情况,多入口是应该分别进行处理。
    • 对于需要事件驱动的 webpack4配置懒加载的,可以看这篇 webpack4优化教程,写得非常全面
    • 一些原生 javaScriptDOM操作等优化会在下面总结

    六、 TCP的四次挥手,断开连接


    终结篇:性能只是 load 时间或者 DOMContentLoaded 时间的问题吗?

    • RAIL

      • Responce响应,研究表明,100ms内对用户的输入操作进行响应,通常会被人类认为是立即响应。时间再长,操作与反应之间的连接就会中断,人们就会觉得它的操作有延迟。例如:当用户点击一个按钮,如果100ms内给出响应,那么用户就会觉得响应很及时,不会察觉到丝毫延迟感。
      • Animaton现如今大多数设备的屏幕刷新频率是60Hz,也就是每秒钟屏幕刷新60次;因此网页动画的运行速度只要达到60FPS,我们就会觉得动画很流畅。
      • Idle RAIL规定,空闲周期内运行的任务不得超过50ms,当然不止RAIL规定,W3C性能工作组的Longtasks标准也规定了超过50毫秒的任务属于长任务,那么50ms这个数字是怎么得来的呢?浏览器是单线程的,这意味着同一时间主线程只能处理一个任务,如果一个任务执行时间过长,浏览器则无法执行其他任务,用户会感觉到浏览器被卡死了,因为他的输入得不到任何响应。为了达到100ms内给出响应,将空闲周期执行的任务限制为50ms意味着,即使用户的输入行为发生在空闲任务刚开始执行,浏览器仍有剩余的50ms时间用来响应用户输入,而不会产生用户可察觉的延迟。
      • Load如果不能在1秒钟内加载网页并让用户看到内容,用户的注意力就会分散。用户会觉得他要做的事情被打断,如果10秒钟还打不开网页,用户会感到失望,会放弃他们想做的事,以后他们或许都不会再回来。
      如何使网页更丝滑?
      • 使用requestAnimationFrame

        • 即便你能保证每一帧的总耗时都小于16ms,也无法保证一定不会出现丢帧的情况,这取决于触发JS执行的方式。假设使用 setTimeout 或 setInterval 来触发JS执行并修改样式从而导致视觉变化;那么会有这样一种情况,因为setTimeout 或 setInterval没有办法保证回调函数什么时候执行,它可能在每一帧的中间执行,也可能在每一帧的最后执行。所以会导致即便我们能保障每一帧的总耗时小于16ms,但是执行的时机如果在每一帧的中间或最后,最后的结果依然是没有办法每隔16ms让屏幕产生一次变化,也就是说,即便我们能保证每一帧总体时间小于16ms,但如果使用定时器触发动画,那么由于定时器的触发时机不确定,所以还是会导致动画丢帧。现在整个Web只有一个API可以解决这个问题,那就是requestAnimationFrame,它可以保证回调函数稳定的在每一帧最开始触发。
      • 避免 FSL

        • 先执行 JS,然后在 JS中修改了样式从而导致样式计算,然后样式的改动触发了布局、绘制、合成。但 JavaScript可以强制浏览器将布局提前执行,这就叫 强制同步布局 FSL

            //读取offsetWidth的值会导致重绘
           const newWidth = container.offsetWidth;
             
            //设置width的值会导致重排,但是for循环内部
            代码执行速度极快,当上面的查询操作导致的重绘
            还没有完成,下面的代码又会导致重排,而且这个重
            排会强制结束上面的重绘,直接重排,这样对性能影响
            非常大。所以我们一般会在循环外部定义一个变量,这里
            面使用变量代替container.offsetWidth;
           boxes[i].style.width = newWidth + 'px';
          }
      • 使用 transform属性去操作动画,这个属性是由合成器单独处理的,所以使用这个属性可以避免布局与绘制。
      • 使用 translateZ(0)开启图层,减少重绘重排。特别在移动端,尽量使用 transform代替 absolute。创建图层的最佳方式是使用will-change,但某些不支持这个属性的浏览器可以使用3D 变形(transform: translateZ(0))来强制创建一个新层。
      • 有兴趣的可以看看这篇文字 前端页面优化
      • 样式的切换最好提前定义好 class,通过 class的切换批量修改样式,避免多次重绘重排
      • 可以先切换 display:none再修改样式
      • 多次的 append 操作可以先插入到一个新生成的元素中,再一次性插入到页面中。
      • 代码复用,函数柯里化,封装高阶函数,将多次复用代码封装成普通函数(俗称方法), React中封装成高阶组件, ES6中可以使用继承, TypeScript中接口继承,类继承,接口合并,类合并。
      • 强力推荐阅读: 阮一峰ES6教程
      • 以及 什么是TypeScript以及入门
    以上都是根据本人的知识点总结得出,后期还会有 React的性能优化方案等出来,路过点个赞收藏收藏~,欢迎提出问题补充~

    基于socket.io快速实现一个实时通讯应用

    $
    0
    0

    随着web技术的发展,使用场景和需求也越来越复杂,客户端不再满足于简单的请求得到状态的需求。实时通讯越来越多应用于各个领域。

    HTTP是最常用的客户端与服务端的通信技术,但是HTTP通信只能由客户端发起,无法及时获取服务端的数据改变。只能依靠定期轮询来获取最新的状态。时效性无法保证,同时更多的请求也会增加服务器的负担。

    WebSocket技术应运而生。

    WebSocket概念

    不同于HTTP半双工协议,WebSocket是基于TCP 连接的全双工协议,支持客户端服务端双向通信。

    WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

    WebSocket API中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。

    HTTP与websocket对比

    实现

    原生实现

    WebSocket对象一共支持四个消息 onopen, onmessage, onclose和onerror。

    建立连接

    通过javascript可以快速的建立一个WebSocket连接:

        var Socket = new WebSocket(url, [protocol] );

    以上代码中的第一个参数 url, 指定连接的URL。第二个参数 protocol是可选的,指定了可接受的子协议。

    同http协议使用 http://开头一样,WebSocket协议的URL使用 ws://开头,另外安全的WebSocket协议使用 wss://开头。

    1. 当Browser和WebSocketServer连接成功后,会触发onopen消息。
        Socket.onopen = function(evt) {};
    1. 如果连接失败,发送、接收数据失败或者处理数据出现错误,browser会触发onerror消息。
        Socket.onerror = function(evt) { };
    1. 当Browser接收到WebSocketServer端发送的关闭连接请求时,就会触发onclose消息。
        Socket.onclose = function(evt) { };

    收发消息

    1. 当Browser接收到WebSocketServer发送过来的数据时,就会触发onmessage消息,参数evt中包含server传输过来的数据。
        Socket.onmessage = function(evt) { };
    1. send用于向服务端发送消息。
        Socket.send();

    socket

    WebSocket是跟随HTML5一同提出的,所以在兼容性上存在问题,这时一个非常好用的库就登场了—— Socket.io

    socket.io封装了websocket,同时包含了其它的连接方式,你在任何浏览器里都可以使用socket.io来建立异步的连接。socket.io包含了服务端和客户端的库,如果在浏览器中使用了socket.io的js,服务端也必须同样适用。

    socket.io是基于 Websocket 的Client-Server 实时通信库。

    socket.io底层是基于engine.io这个库。engine.io为 socket.io 提供跨浏览器/跨设备的双向通信的底层库。engine.io使用了 Websocket 和 XHR 方式封装了一套 socket 协议。在低版本的浏览器中,不支持Websocket,为了兼容使用长轮询(polling)替代。

    engine.io

    API文档

    Socket.io允许你触发或响应自定义的事件,除了connect,message,disconnect这些事件的名字不能使用之外,你可以触发任何自定义的事件名称。

    建立连接

        const socket = io("ws://0.0.0.0:port"); // port为自己定义的端口号
        let io = require("socket.io")(http);
        io.on("connection", function(socket) {})

    消息收发

    一、发送数据

        socket.emit(自定义发送的字段, data);

    二、接收数据

        socket.on(自定义发送的字段, function(data) {
            console.log(data);
        })

    断开连接

    一、全部断开连接

        let io = require("socket.io")(http);
        io.close();

    二、某个客户端断开与服务端的链接

        // 客户端
        socket.emit("close", {});
        // 服务端
        socket.on("close", data => {
            socket.disconnect(true);
        });

    room和namespace

    有时候websocket有如下的使用场景:1.服务端发送的消息有分类,不同的客户端需要接收的分类不同;2.服务端并不需要对所有的客户端都发送消息,只需要针对某个特定群体发送消息;

    针对这种使用场景,socket中非常实用的namespace和room就上场了。

    先来一张图看看namespace与room之间的关系:

    namespace与room的关系

    namespace

    服务端

        io.of("/post").on("connection", function(socket) {
            socket.emit("new message", { mess: `这是post的命名空间` });
        });
        
        io.of("/get").on("connection", function(socket) {
            socket.emit("new message", { mess: `这是get的命名空间` });
        });

    客户端

        // index.js
        const socket = io("ws://0.0.0.0:****/post");
        socket.on("new message", function(data) {
            console.log('index',data);
        }
        //message.js
        const socket = io("ws://0.0.0.0:****/get");
        socket.on("new message", function(data) {
            console.log('message',data);
        }

    room

    客户端

        //可用于客户端进入房间;
        socket.join('room one');
        //用于离开房间;
        socket.leave('room one');

    服务端

        io.sockets.on('connection',function(socket){
            //提交者会被排除在外(即不会收到消息)
            socket.broadcast.to('room one').emit('new messages', data);
            // 向所有用户发送消息
            io.sockets.to(data).emit("recive message", "hello,房间中的用户");      
        }

    用socket.io实现一个实时接收信息的例子

    终于来到应用的阶段啦,服务端用 node.js模拟了服务端接口。以下的例子都在本地服务器中实现。

    服务端

    先来看看服务端,先来开启一个服务,安装 expresssocket.io

    安装依赖

        npm install --Dev express
        npm install --Dev socket.io

    构建node服务器

        let app = require("express")();
        let http = require("http").createServer(handler);
        let io = require("socket.io")(http);
        let fs = require("fs");
        http.listen(port); //port:输入需要的端口号
        
        function handler(req, res) {
          fs.readFile(__dirname + "/index.html", function(err, data) {
            if (err) {
              res.writeHead(500);
              return res.end("Error loading index.html");
            }
            res.writeHead(200);
            res.end(data);
          });
        }
        io.on("connection", function(socket) {
            console.log('连接成功');
            //连接成功之后发送消息
            socket.emit("new message", { mess: `初始消息` });
            
        });

    客户端

    核心代码——index.html(向服务端发送数据)

    <div>发送信息</div><input placeholder="请输入要发送的信息" /><button onclick="postMessage()">发送</button>
        // 接收到服务端传来的name匹配的消息
        socket.on("new message", function(data) {
          console.log(data);
        });
        function postMessage() {
          socket.emit("recive message", {
            message: content,
            time: new Date()
          });
          messList.push({
            message: content,
            time: new Date()
          });
        }

    核心代码——message.html(从服务端接收数据)

        socket.on("new message", function(data) {
          console.log(data);
        });

    效果

    实时通讯效果
    实时通讯效果

    客户端全部断开连接
    全部断开

    某客户端断开连接
    某客户端断开连接

    namespace应用
    namespace

    加入房间
    加入房间

    离开房间
    离开房间

    框架中的应用

    npm install socket.io-client

        const socket = require('socket.io-client')('http://localhost:port');
    
        componentDidMount() {
            socket.on('login', (data) => {
                console.log(data)
            });
            socket.on('add user', (data) => {
                console.log(data)
            });
            socket.on('new message', (data) => {
                console.log(data)
            });
        }

    分析webSocket协议

    Headers

    Headers

    请求包

        Accept-Encoding: gzip, deflate
        Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
        Cache-Control: no-cache
        Connection: Upgrade
        Cookie: MEIQIA_VISIT_ID=1IcBRlE1mZhdVi1dEFNtGNAfjyG; token=0b81ffd758ea4a33e7724d9c67efbb26; io=ouI5Vqe7_WnIHlKnAAAG
        Host: 0.0.0.0:2699
        Origin: http://127.0.0.1:5500
        Pragma: no-cache
        Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
        Sec-WebSocket-Key: PJS0iPLxrL0ueNPoAFUSiA==
        Sec-WebSocket-Version: 13
        Upgrade: websocket
        User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1

    请求包说明:

    • 必须是有效的http request 格式;
    • HTTP request method 必须是GET,协议应不小于1.1 如: Get / HTTP/1.1;
    • 必须包括Upgrade头域,并且其值为“websocket”,用于告诉服务器此连接需要升级到websocket;
    • 必须包括”Connection” 头域,并且其值为“Upgrade”;
    • 必须包括”Sec-WebSocket-Key”头域,其值采用base64编码的随机16字节长的字符序列;
    • 如果请求来自浏览器客户端,还必须包括Origin头域 。 该头域用于防止未授权的跨域脚本攻击,服务器可以从Origin决定是否接受该WebSocket连接;
    • 必须包括“Sec-webSocket-Version”头域,是当前使用协议的版本号,当前值必须是13;
    • 可能包括“Sec-WebSocket-Protocol”,表示client(应用程序)支持的协议列表,server选择一个或者没有可接受的协议响应之;
    • 可能包括“Sec-WebSocket-Extensions”, 协议扩展, 某类协议可能支持多个扩展,通过它可以实现协议增强;
    • 可能包括任意其他域,如cookie.

    应答包

    应答包说明:

        Connection: Upgrade
        Sec-WebSocket-Accept: I4jyFwm0r1J8lrnD3yN+EvxTABQ=
        Sec-WebSocket-Extensions: permessage-deflate
        Upgrade: websocket
    • 必须包括Upgrade头域,并且其值为“websocket”;
    • 必须包括Connection头域,并且其值为“Upgrade”;
    • 必须包括Sec-WebSocket-Accept头域,其值是将请求包“Sec-WebSocket-Key”的值,与”258EAFA5-E914-47DA-95CA-C5AB0DC85B11″这个字符串进行拼接,然后对拼接后的字符串进行sha-1运算,再进行base64编码,就是“Sec-WebSocket-Accept”的值;
    • 应答包中冒号后面有一个空格;
    • 最后需要两个空行作为应答包结束。

    请求数据

        EIO: 3
        transport: websocket
        sid: 8Uehk2UumXoHVJRzAAAA
    • EIO:3 表示使用的是engine.io协议版本3
    • transport 表示传输采用的类型
    • sid: session id (String)

    Frames

    WebSocket协议使用帧(Frame)收发数据,在控制台->Frames中可以查看发送的帧数据。

    其中帧数据前的数字代表什么意思呢?

    这是 Engine.io协议,其中的数字是数据包编码:

    <Packet type id> [<data>]

    • 0 open——在打开新传输时从服务器发送(重新检查)
    • 1 close——请求关闭此传输,但不关闭连接本身。
    • 2 ping——由客户端发送。服务器应该用包含相同数据的乓包应答

      客户端发送:2probe探测帧
    • 3 pong——由服务器发送以响应ping数据包。

      服务器发送:3probe,响应客户端
    • 4 message——实际消息,客户端和服务器应该使用数据调用它们的回调。
    • 5 upgrade——在engine.io切换传输之前,它测试,如果服务器和客户端可以通过这个传输进行通信。如果此测试成功,客户端发送升级数据包,请求服务器刷新其在旧传输上的缓存并切换到新传输。
    • 6 noop——noop数据包。主要用于在接收到传入WebSocket连接时强制轮询周期。

    实例

    发送数据

    接收数据

    以上的截图是上述例子中数据传输的实例,分析一下大概过程就是:

    1. connect握手成功
    2. 客户端会发送2 probe探测帧
    3. 服务端发送响应帧3probe
    4. 客户端会发送内容为5的Upgrade帧
    5. 服务端回应内容为6的noop帧
    6. 探测帧检查通过后,客户端停止轮询请求,将传输通道转到websocket连接,转到websocket后,接下来就开始定期(默认是25秒)的 ping/pong
    7. 客户端、服务端收发数据,4表示的是engine.io的message消息,后面跟随收发的消息内容

    为了知道Client和Server链接是否正常,项目中使用的ClientSocket和ServerSocket都有一个心跳的线程,这个线程主要是为了检测Client和Server是否正常链接,Client和Server是否正常链接主要是用ping pong流程来保证的。

    该心跳定期发送的间隔是socket.io默认设定的25m,在上图中也可观察发现。该间隔可通过 配置修改。

    socket通信流程

    参考 engine.io-protocol

    参考文章

    Web 实时推送技术的总结
    engine.io 原理详解

    广而告之

    本文发布于 薄荷前端周刊,欢迎Watch & Star ★,转载请注明出处。

    欢迎讨论,点个赞再走吧 。◕‿◕。 ~

    让前端开发者失业的技术,Flutter Web初体验

    $
    0
    0
    Flutter是一种新型的“客户端”技术。它的最终目标是替代包含几乎所有平台的开发:iOS,Android,Web,桌面;做到了一次编写,多处运行。掌握Flutter web可能是Web前端开发者翻盘的唯一机会。

    图片描述

    在前些日子举办的Google IO 2019 年度开发者大会上,Flutter web作为一个很亮眼的技术受到了开发者的追捧。这是继Flutter支持Android、IOS等设备之后,又一个里程碑式的版本,后续还会支持windows、linux、Macos、chroms等其他嵌入式设备。Flutter本身是一个类似于RN、WEEX、hHybrid等多端统一跨平台解决方案,真正做到了一次编写,多处运行,它的发展超出了很多人的想象,值得前端开发者去关注,今天我们来体验一下Flutter Web。

    概览

    先了解一下Flutter, 它是一个由谷歌开发的开源移动应用软件开发工具包,用于为Android和iOS开发应用,同时也将是Google Fuchsia下开发应用的主要工具。自从FLutter 1.5.4版本之后,支持了Web端的开发。它采用Dart语言来进行开发,与JavaScript相比,Dart在 JIT(即时编译)模式下,速度与 JavaScript基本持平。但是当Dart以 AOT模式运行时,Dart性能要高于JavaScript。

    Flutter内置了UI界面,与Hybrid App、React Native这些跨平台技术不同,Flutter既没有使用WebView,也没有使用各个平台的原生控件,而是本身实现一个统一接口的渲染引擎来绘制UI,Dart直接编译成了二进制文件,这样做可以保证不同平台UI的一致性。它也可以复用Java、Kotlin、Swift或OC代码,访问Android和iOS上的原生系统功能,比如蓝牙、相机、WiFi等等。我们公司的Now直播、企鹅辅导等项目、阿里的闲鱼等商业化项目已经大量在使用。

    架构

    Flutter 的 Mobile 架构

    Flutter的顶层是用drat编写的框架,包含Material(Android风格UI)和Cupertino(iOS风格)的UI界面,下面是通用的Widgets(组件),之后是一些动画、绘制、渲染、手势库等。
    框架下面是引擎,主要用C / C ++编写,引擎包含三个核心库,Skia是Flutter的2D渲染引擎,它是Google的一个2D图形处理函数库,包含字型、坐标转换,以及点阵图,都有高效能且简洁的表现。Skia是跨平台的,并提供了非常友好的API。第二是Dart 运行时环境以及第三文本渲染布局引擎。
    最底层的嵌入层,它所关心的是如何将图片组合到屏幕上,渲染变成像素。这一层的功能是用来解决跨平台的。

    了解了FLutter 之后,我来说一下今天的重头戏,Flutter for Web。要想知道Flutter为什么能在web上运行,得先来看看它的架构。

    Flutter 的 web架构

    通过对比,可以发现,web框架层和mobile的几乎一模一样。因此只需要重新实现一下引擎和嵌入层,不用变动Flutter API就可以完全可以将UI代码从Android / IOS Flutter App移植到Web。Dart能够使用Dart2Js编译器把Dart代码编译成Js代码。大多数原生App元素能够通过DOM实现,DOM实现不了的元素可以通过Canvas来实现。

    安装

    Flutter Web开发环境搭建,以我的windows环境为例进行讲解,其他环境类似,安装环境比较繁琐,需要耐心,有Android开发经验最好。

    1、在 Windows平台开发的话,官方的环境要求是Windows 7 SP1或更高版本(64位)。

    2、 Java环境,安装Java 1.8 + 版本之上,并配置环境变量,因为android开发依赖Java环境。

    对于Java程序开发而言,主要会使用JDK的两个命令:javac.exe、java.exe。路径:C:Javajdk1.8.0_181bin。但是这些命令由于不属于windows自己的命令,所以要想使用,就需要进行路径配置。单击“计算机-属性-高级系统设置”,单击“环境变量”。在“系统变量”栏下单击“新建”,创建新的系统环境变量(或用户变量,等效)。

    图片描述

    (1)新建->变量名"JAVA_HOME",变量值"C:Javajdk1.8.0_181"(即JDK的安装路径)
    (2)编辑->变量名"Path",在原变量值的最后面加上“;%JAVA_HOME%bin;%JAVA_HOME%jrebin”
    (3)新建->变量名“CLASSPATH”,变量值“.;%JAVA_HOME%lib;%JAVA_HOME%libdt.jar;%JAVA_HOME%libtools.jar”

    3、 Android Studio编辑器,安装Android Studio, 3.0或更高版本。我们需要用它来导入Android license和管理Android SDK以及Android虚拟机。(默认安装即可)

    安装完成之后设置代理,左上角的File-》setting-》搜索proxy,设置公司代理,用来加速下载Android SDK。

    图片描述

    之后点击右上角方盒按钮(SDK Manager),用来选择安装SDK版本,最好选Android 9版本,API28,会有一个很长时间的下载过程。SDK是开发必须的代码库。默认情况下,Flutter使用的Android SDK版本是基于你的 adb (Android Debug Bridge,管理连接手机,已打包在SDK)工具版本。 如果您想让Flutter使用不同版本的Android SDK,则必须将该 ANDROID_HOME 环境变量设置为SDK安装目录。

    图片描述

    右上角有个小手机类型的按钮(AVD Manager),用来设置Android模拟器,创建一个虚拟机。如果你有一台安卓手机,也可以连接USB接口,替代虚拟机。这个过程是调试必须的。安装完成之后,在 AVD (Android Virtual Device Manager) 中,点击工具栏的 Run。模拟器启动并显示所选操作系统版本或设备的启动画面。代表了正确安装。

    图片描述

    4、 安装Flutter SDK

    下载Flutter SDK有多种方法,看看哪种更适合自己:
    Flutter官网下载最新Beta版本的进行安装: https://flutter.dev/docs/deve...
    也可Flutter github项目中去下载,地址为: https://github.com/flutter/fl...
    版本越新越好,不要低于1.5.4。

    将安装包zip解压到你想安装Flutter SDK的路径(如:C:srcflutter;注意,不要将flutter安装到需要一些高权限的路径如C:Program Files)。记住,之后往环境变量的path中添加;C:srcflutterbin,以便于你能在命令行中使用flutter。

    使用镜像
    由于在国内安装Flutter相关的依赖可能会受到限制,Flutter官方为中国开发者搭建了临时镜像,大家可以将如下环境变量加入到用户环境变量中:
    PUB_HOSTED_URL:https://pub.flutter-io.cn
    FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn

    图片描述

    5、安装Dart与Pub。安装webdev、stagehand

    Pub是Dart的包管理工具,类似npm,捆绑安装。
    Dart安装版地址: http://www.gekorm.com/dart-wi...
    默认安装即可,安装之后记住Dart的路径,并且配置到环境变量path中,以便于可以在命令行中使用dart与pub,默认的路径是:C:Program FilesDartdart-sdkbin
    先安装stagehand,stagehand是创建项目必须的工具。查看一下 C:\Users\chunpengliu\AppData\Roaming\Pub\Cache\bin目录下是否包含stagehand和webdev,如果有,添加到环境变量的path里面,如果没有,按下面方法安装:

    pub global activate stagehand

    webdev是一个类似于Koa的web服务器,执行以下命令安装

    pub global activate webdev
    # or
    flutter packages pub global activate webdev

    6、配置编辑器安装Flutter和Dart插件

    Flutter插件是用来支持Flutter开发工作流 (运行、调试、热重载等)。
    Dart插件 提供代码分析 (输入代码时进行验证、代码补全等)。Android Studio的设置在File-》setting-》plugins-》搜索Flutter和Dart,安装之后重启。

    图片描述

    VS code的设置在extension-》搜索Flutter和Dart,安装之后重启。

    图片描述

    7、运行 flutter doctor

    打开一个新的命令提示符或PowerShell窗口并运行以下命令以查看是否需要安装任何依赖项来完成安装:

    flutter doctor

     这是一个漫长的过程,flutter会检测你的环境,并安装所有的依赖,直至:No issues found!,如果有缺失,会就会再那一项前面打x。你需要一一解决。

    图片描述

    一切就绪!

    创建应用

    1、启动 VS Code

    调用 View>Command Palette…(快捷键ctrl+shift+p)
    输入 ‘flutter’, 然后选择 ‘Flutter: New web Project’ 

    图片描述

    输入 Project 名称 (如flutterweb), 然后按回车键
    指定放置项目的位置,然后按蓝色的确定按钮
    等待项目创建继续,并显示main.dart文件。到此,一个Demo创建完成。

    图片描述

    我们看到了熟悉的HTML文件以及项目入口文件main.dart。
    web目录下的index.html是项目的入口文件。main.dart初始化文件,图片相关资源放在此目录。
    lib目录下的main.dart,是主程序代码所在的地方。
    每个pub包或者Flutter项目都包含一个pubspec.yaml。它包含与此项目相关的依赖项和元数据。
    analysis_options.yaml是配置项目的lint规则。
    /dart_tool 是项目打包运行编译生成的文件,页面主程序main.dart.js就在其中。

    2、调试Demo,打开命令行,进入到项目根目录,执行:

    webdev flutterweb

     编译、打包完成之后,自动启动(或者按F5)默认浏览器,看一下转换后的HTML页面结构:

    图片描述

    lib/main.dart是主程序,源码非常简单,整个页面用widgets堆叠而成,区别于传统的html和css。

    import 'package:flutter_web/material.dart';
    
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: MyHomePage(title: 'Flutter Demo Home Page'),
        );
      }
    }
    
    class MyHomePage extends StatelessWidget {
      MyHomePage({Key key, this.title}) : super(key: key);
    
      final String title;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Text('Hello, World!',
                ),
              ],
            ),
          ), 
        );
      }
    }

    区别与flutter App应用,我们导入的是flutter_web/material.dart库而非flutter/material.dart,这是因为目前App的接口并非和Web的完全通用,不过随着谷歌开发的继续,它们最终会被合并到一块。
    打开pubspec.yaml(类似于package.json),可以看到只有两个依赖包flutter_web和flutter_web_ui,这两个都已在github上开源。dev的依赖页非常少,两个编译相关的包,和一个静态文件分析包。

    name: flutterweb
    description: An app built using Flutter for web
    environment:
      # You must be using Flutter >=1.5.0 or Dart >=2.3.0
      sdk: '>=2.3.0-dev.0.1 <3.0.0'
    dependencies:
      flutter_web: any
      flutter_web_ui: any
    dev_dependencies:
      build_runner: ^1.4.0
      build_web_compilers: ^2.0.0
      pedantic: ^1.0.0
    dependency_overrides:
      flutter_web:
        git:
          url: https://github.com/flutter/flutter_web
          path: packages/flutter_web
      flutter_web_ui:
        git:
          url: https://github.com/flutter/flutter_web
          path: packages/flutter_web_ui

    实战

    接下来,我们创建一个具有图文功能的下载,根据实例来学习flutter,我们将实现下图的页面。它是一个上下两栏的布局,下栏又分为左右两栏。

    图片描述

    第一步:更改主应用内容,打开lib/main.dart文件,替换class MyApp,首先是根组件MyApp,它是一个类组件继承自无状态组件,是项目的主题配置,在home属性中调用了Home组件:

    class MyApp extends StatelessWidget {
      // 应用的根组件
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: '腾讯新闻客户端下载页', //meta 里的titile
          debugShowCheckedModeBanner: false, // 关闭调试bar
          theme: ThemeData(
            primarySwatch: Colors.blue, // 页面主题 Material风格
          ),
          home: Home(), // 启动首页
        );
      }
    }

    第二步,在Home类中,是我们要渲染的页面顶导,运用了AppBar组件,它包括了一个居中的页面标题和居右的搜索按钮。文本可以像css一样设置外观样式。

    class Home extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          backgroundColor: Colors.white,
          appBar: AppBar(
            backgroundColor: Colors.white,
            elevation: 0.0,
            centerTitle: true,
            title: Text( // 中心文本
              "下载页",
              style:
                  TextStyle(color: Colors.black, fontSize: 16.0, fontWeight: FontWeight.w500),
            ),
    // 搜索图标及特性
            actions: <Widget>[ 
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 20.0),
                child: Icon(
                  Icons.search,
                  color: Colors.black,
                ),
              )
            ],
          ),
    //调用body渲染类,此处可以添加多个方法调用
          body: Stack(
            children: [
                Body() 
            ],
          ),
        );
      }
    }

     第三步,创建页面主体内容,一张图加多个文本,使用了文本组件和图片组件,页面结构采用了flex布局,由于两个Expanded的Flex值均为1,因此将在两个组件之间平均分配空间。SizedBox组件相当于一个空盒子,用来设置margin的距离

    class Body extends StatelessWidget {
      const Body({Key key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return Row(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            Expanded( // 左侧
              flex: 1,
              child: Image.asset(// 图片组件
                "background-image.jpg", // 这是一张在web/asserts/下的背景图
                fit: BoxFit.contain,
              ),
            ),
            const SizedBox(width: 90.0),
            Expanded( // 右侧
              flex:1,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text( // 文本组件
                    "腾讯新闻",
                    style: TextStyle(
                        color: Colors.black, fontWeight: FontWeight.w600, fontSize: 50.0, fontFamily: 'Merriweather'),
                  ),
                  const SizedBox(height: 14.0),// SizedBox用来增加间距
                  Text(
                    "腾讯新闻是腾讯公司为用户打造的一款全天候、全方位、及时报道的新闻产品,为用户提供高效优质的资讯、视频和直播服务。资讯超新超全,内容独家优质,话题评论互动。",
                    style: TextStyle(
                        color: Colors.black, fontWeight: FontWeight.w400, fontSize: 24.0, fontFamily: "Microsoft Yahei"),
                    textAlign: TextAlign.justify,
                  ),
                  const SizedBox(height: 20.0), 
                  FlatButton(
                    onPressed: () {}, // 下载按钮的响应事件
                    color: Color(0xFFCFE8E4),
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(16.0),
                    ),
                    child: Padding(
                      padding: const EdgeInsets.all(12.0),
                      child: Text("点击下载", style: TextStyle(fontFamily: "Open Sans")),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 100.0),
          ],
        );
      }
    }

     到此,页面创建结束,保存,运行webdev serve,就可以看到效果了。

    总结

    FLutter web是Flutter 的一个分支,在开发完App之后,UI层面的FLutter代码在不修改的情况下可以直接编译为Web版,基本可以做到代码100%复用,体验还不错。目前Flutter web作为预览版无论从性能上、易用上还是布局上都超出了预期,触摸体验挺好,虽然体验比APP差一些,但是比传统的web要好很多。试想一下 Flutter 开发iOS 和Android的App 还免费赠送一份Web版,并且比传统的web开发出来的体验还好。Write once ,Run anywhere。何乐而不为?

    我觉得随着谷歌的持续优化,等到正式版发布之后,开发体验越来越好,Flutter开发者会吃掉H5很大一部分份额。Flutter 可能会给目前客户端的开发模式带来一些变革以及分工的变化, Flutter目前的开发体验不是很好, 但是潜力很大,值得前端人员去学习。

    但是目前还是有一部分问题,Flutter web是为客户端开发(尤其是安卓)人员开发准备的,对于前端理解来说学习成本有点高。目前FLutter web和 flutter 还是两个项目,编译环境也是分开的,需要在代码里面修改Flutter相关库的引用为Flutter_web,组件还不能达到完全通用,这个谷歌承诺正在解决中,谷歌的最终目标是Web、移动App、桌面端win mac linux、以及嵌入式版的Flutter代码库之间保持100%的代码可移植性。

    个人感觉,开发体验还不太好,还有很多坑要去踩,版本变更很快。还有社区资源稀少的问题,需要一定长期的积累。兼容性问题,代码转换后大量使用了web components,除了chrome之外,兼容性还是有些问题。

    安利时间

    我们在web开发过程中,都见过或者使用过一些奇技淫巧,这种技术我们统称为黑魔法,这些黑魔法散落在各个角落,为了方便大家查阅和学习,我们做了收集、整理和归类,并在github上做了一个项目—— awesome-blackmargic,希望各位爱钻研的开发者能够喜欢,也希望大家可以把自己的独门绝技分享出来,如果有兴趣可以给我们发pr。

    如果你对Flutter感兴趣,想进一步了解Flutter,加入我们的QQ群(784383520)吧!

    使用Node.js爬取任意网页资源并输出高质量PDF文件到本地~

    $
    0
    0

    detail?ct=503316480&z=0&ipn=d&word=%E6%B5%B7%E8%BE%B9%E5%A3%81%E7%BA%B8&hs=2&pn=0&spn=0&di=10120&pi=0&rn=1&tn=baiduimagedetail&is=0%2C0&ie=utf-8&oe=utf-8&cl=2&lm=-1&cs=3590237416%2C2845421745&os=3026828862%2C3835093178&simid=0%2C0&adpicid=0&lpn=0&ln=30&fr=ala&fm=&sme=&cg=&bdtype=0&oriquery=%E6%B5%B7%E8%BE%B9%E5%A3%81%E7%BA%B8&objurl=http%3A%2F%2Fabc.2008php.com%2F2017_Website_appreciate%2F2017-10-09%2F20171009204205.jpg&fromurl=ippr_z2C%24qAzdH3FAzdH3Fwkv_z%26e3Bdaabrir_z%26e3Bv54AzdH3Fp7h7AzdH3Fda80AzdH3F8aalAzdH3Flcblld_z%26e3Bip4s&gsm=0&islist=&querylist=

    本文适合无论是否有爬虫以及 Node.js基础的朋友观看~
    需求:
    • 使用 Node.js爬取网页资源,开箱即用的配置
    • 将爬取到的网页内容以 PDF格式输出
    如果你是一名技术人员,那么可以看我接下来的文章,否则,请直接移步到我的 github仓库,直接看文档使用即可

    仓库地址: 附带文档和源码,别忘了给个 star

    本需求使用到的技术: Node.jspuppeteer

    • puppeteer官网地址: puppeteer地址
    • Node.js官网地址: 链接描述
    • Puppeteer是谷歌官方出品的一个通过 DevTools协议控制 headless ChromeNode库。可以通过Puppeteer的提供的api直接控制Chrome模拟大部分用户操作来进行UI Test或者作为爬虫访问页面来收集数据。
    • 环境和安装
    • Puppeteer本身依赖6.4以上的Node,但是为了异步超级好用的 async/await,推荐使用7.6版本以上的Node。另外headless Chrome本身对服务器依赖的库的版本要求比较高,centos服务器依赖偏稳定,v6很难使用headless Chrome,提升依赖版本可能出现各种服务器问题(包括且不限于无法使用ssh),最好使用高版本服务器。(建议使用最新版本的 Node.js

    小试牛刀,爬取京东资源

    const puppeteer = require('puppeteer'); //  引入依赖  
    (async () => {   //使用async函数完美异步 
        const browser = await puppeteer.launch();  //打开新的浏览器
        const page = await browser.newPage();   // 打开新的网页 
        await page.goto('https://www.jd.com/');  //前往里面 'url' 的网页
        const result = await page.evaluate(() => {   //这个result数组包含所有的图片src地址
            let arr = []; //这个箭头函数内部写处理的逻辑  
            const imgs = document.querySelectorAll('img');
            imgs.forEach(function (item) {
                arr.push(item.src)
            })
            return arr 
        });
        // '此时的result就是得到的爬虫数据,可以通过'fs'模块保存'
    })()
    
      复制过去 使用命令行命令 ` node 文件名 ` 就可以运行获取爬虫数据了 
    这个 puppeteer 的包 ,其实是替我们开启了另一个浏览器,重新去开启网页,获取它们的数据。
    
    • 上面只爬取了京东首页的图片内容,假设我的需求进一步扩大,需要爬取京东首页

    中的所有 <a> 标签对应的跳转网页中的所有 title的文字内容,最后放到一个数组中

    • 我们的 async函数上面一共分了五步, 只有 puppeteer.launch() ,

    browser.newPage(), browser.close()是固定的写法。

    • page.goto指定我们去哪个网页爬取数据,可以更换内部url地址,也可以多次

    调用这个方法。

    • page.evaluate这个函数,内部是处理我们进入想要爬取网页的数据逻辑
    • page.goto page.evaluate两个方法,可以在 async内部调用多次,

    那意味着我们可以先进入京东网页,处理逻辑后,再次调用 page.goto这个函数,

    注意,上面这一切逻辑,都是 puppeteer这个包帮我们在看不见的地方开启了另外一个
    浏览器,然后处理逻辑,所以最终要调用 browser.close()方法关闭那个浏览器。

    这时候我们对上一篇的代码进行优化,爬取对应的资源。

     const puppeteer = require('puppeteer');
    (async () => {
        const browser = await puppeteer.launch();
        const page = await browser.newPage();
        await page.goto('https://www.jd.com/');
        const hrefArr = await page.evaluate(() => {
            let arr = [];
            const aNodes = document.querySelectorAll('.cate_menu_lk');
            aNodes.forEach(function (item) {
                arr.push(item.href)
            })
            return arr
        });
        let arr = [];
        for (let i = 0; i < hrefArr.length; i++) {
            const url = hrefArr[i];
            console.log(url) //这里可以打印 
            await page.goto(url);
            const result = await page.evaluate(() => { //这个方法内部console.log无效 
                
                  return  $('title').text();  //返回每个界面的title文字内容
            });
            arr.push(result)  //每次循环给数组中添加对应的值
        }
        console.log(arr)  //得到对应的数据  可以通过Node.js的 fs 模块保存到本地
        await browser.close()
    })()
    
    上面有天坑 page.evaluate函数内部的console.log不能打印,而且内部不能获取外部的变量,只能return返回,
    使用的选择器必须先去对应界面的控制台实验过能不能选择DOM再使用,比如京东无法使用querySelector。这里由于
    京东的分界面都使用了jQuery,所以我们可以用jQuery,总之他们开发能用的选择器,我们都可以用,否则就不可以。

    接下来我们直接来爬取 Node.js的官网首页然后直接生成 PDF

    无论您是否了解Node.js和puppeteer的爬虫的人员都可以操作,请您一定万分仔细阅读本文档并按顺序执行每一步
    本项目实现需求:给我们一个网页地址,爬取他的网页内容,然后输出成我们想要的PDF格式文档,请注意,是高质量的PDF文档
    • 第一步,安装 Node.js ,推荐 http://nodejs.cn/download/Node.js的中文官网下载对应的操作系统包
    • 第二步,在下载安装完了 Node.js后, 启动 windows命令行工具(windows下启动系统搜索功能,输入cmd,回车,就出来了)
    • 第三步 需要查看环境变量是否已经自动配置,在命令行工具中输入 node -v,如果出现 v10. ***字段,则说明成功安装 Node.js
    • 第四步 如果您在第三步发现输入 node -v还是没有出现 对应的字段,那么请您重启电脑即可
    • 第五步 打开本项目文件夹,打开命令行工具(windows系统中直接在文件的 url地址栏输入 cmd就可以打开了),输入 npm i cnpm nodemon -g
    • 第六步 下载 puppeteer爬虫包,在完成第五步后,使用 cnpm i puppeteer --save 命令 即可下载
    • 第七步 完成第六步下载后,打开本项目的 url.js,将您需要爬虫爬取的网页地址替换上去(默认是 http://nodejs.cn/)
    • 第八步 在命令行中输入 nodemon index.js即可爬取对应的内容,并且自动输出到当前文件夹下面的 index.pdf文件中
    TIPS: 本项目设计思想就是一个网页一个 PDF文件,所以每次爬取一个单独页面后,请把 index.pdf拷贝出去,然后继续更换 url地址,继续爬取,生成新的 PDF文件,当然,您也可以通过循环编译等方式去一次性爬取多个网页生成多个 PDF文件。

    对应像京东首页这样的开启了图片懒加载的网页,爬取到的部分内容是 loading状态的内容,对于有一些反爬虫机制的网页,爬虫也会出现问题,但是绝大多数网站都是可以的

    const puppeteer = require('puppeteer');
    const url = require('./url');
    (async () => {
        const browser = await puppeteer.launch({ headless: true })
        const page = await browser.newPage()
        //选择要打开的网页  
        await page.goto(url, { waitUntil: 'networkidle0' })
        //选择你要输出的那个PDF文件路径,把爬取到的内容输出到PDF中,必须是存在的PDF,可以是空内容,如果不是空的内容PDF,那么会覆盖内容
        let pdfFilePath = './index.pdf';
        //根据你的配置选项,我们这里选择A4纸的规格输出PDF,方便打印
        await page.pdf({
            path: pdfFilePath,
            format: 'A4',
            scale: 1,
            printBackground: true,
            landscape: false,
            displayHeaderFooter: false
        });
        await browser.close()
    })()

    文件解构设计

    clipboard.png

    数据在这个时代非常珍贵,按照网页的设计逻辑,选定特定的 href的地址,可以先直接获取对应的资源,也可以通过再次使用 page.goto方法进入,再调用 page.evaluate()处理逻辑,或者输出对应的 PDF文件,当然也可以一口气输出多个 PDF文件~
    这里就不做过多介绍了,毕竟 Node.js 是可以上天的,或许未来它真的什么都能做。这么优质简短的教程,请收藏
    或者转发给您的朋友,谢谢。

    [译]保持Node.js的速度-创建高性能Node.js Servers的工具、技术和提示

    $
    0
    0

    pre-tips

    本文翻译自: Keeping Node.js Fast: Tools, Techniques, And Tips For Making High-Performance Node.js Servers

    原文地址: https://www.smashingmagazine....

    中文标题:保持Node.js的速度-创建高性能Node.js Servers的工具、技术和提示

    快速摘要

    Node 是一个非常多彩的平台,而创建network服务就是其非常重要的能力之一。在本文我们将关注最主流的: HTTP Web servers.

    引子

    如果你已经使用Node.js足够长的时间,那么毫无疑问你会碰到比较痛苦的速度问题。JavaScript是一种事件驱动的、异步的语言。这很明显使得对性能的推理变得棘手。Node.js的迅速普及使得我们必须寻找适合这种server-side javacscript的工具、技术。

    当我们碰到性能问题,在浏览器端的经验将无法适用于服务器端。所以我们如何确保一个Node.js代码是快速的且能达到我们的要求呢?让我们来动手看一些实例

    工具

    我们需要一个工具来压测我们的server从而测量性能。比如,我们使用 autocannon

    npm install -g autocannon // 或使用淘宝源cnpm, 腾讯源tnpm

    其他的Http benchmarking tools 包括 Apache Bench(ab)wrk2, 但AutoCannon是用Node写的,对前端来说会更加方便并易于安装,它可以非常方便的安装在 Windows、Linux 和Mac OS X.

    当我们安装了基准性能测试工具,我们需要用一些方法去诊断我们的程序。一个很不错的诊断性能问题的工具便是 Node Clinic。它也可以用npm安装:

    npm install -g clinic

    这实际上会安装一系列套件,我们将使用 Clinic Doctor
    和 Clinic Flame (一个 ox的封装)

    译者注: ox是一个自动剖析cpu并生成node进程火焰图的工具; 而clinic Flame就是基于ox的封装。
    另一方面, clinic工具本身其实是一系列套件的组合,它不同的子命令分别会调用到不同的子模块,例如:
    • 医生诊断功能。The doctor functionality is provided by Clinic.js Doctor.
    • 气泡诊断功能。The bubbleprof functionality is provided by Clinic.js Bubbleprof.
    • 火焰图功能。 The flame functionality is provided by Clinic.js Flame.)

    tips: 对于本文实例,需要 Node 8.11.2 或更高版本

    代码示例

    我们的例子是一个只有一个资源的简单的 REST server:暴露一个 GET 访问的路由 /seed/v1,返回一个大 JSON 载荷。 server端的代码就是一个app目录,里面包括一个 packkage.json (依赖 restify 7.1.0)、一个 index.js和 一个 util.js (译者注: 放一些工具函数)

    // index.js
    const restify = require('restify')
    const server = restify.createServer()
    const { etagger, timestamp, fetchContent } from './util'
    
    server.use(etagger.bind(server)) // 绑定etagger中间件,可以给资源请求加上etag响应头
    
    server.get('/seed/v1', function () {
      fetchContent(req.url, (err, content) => {
        if (err) {
          return next(err)
        }
        res.send({data: content, ts: timestamp(), url: req.url})
        next()
      })
    })
    
    server.listen(8080, function () {
      cosnole.log(' %s listening at %s',  server.name, server.url)
    })
    // util.js
    const restify = require('restify')
    const crypto = require('crypto')
    
    module.exports = function () {
        const content = crypto.rng('5000').toString('hex') // 普通有规则的随机
    
        const fetchContent = function (url, cb) {
            setImmediate(function () {
            if (url !== '/seed/v1') return restify.errors.NotFoundError('no api!')
                cb(content)
            })
        }
        let last = Date.now()
        const TIME_ONE_MINUTE = 60000
        const timestamp = function () {
          const now = Date.now()
          if (now - last >= TIME_ONE_MINITE) {
              last = now
          }
          return last
        }
        const etagger = function () {
            const cache = {}
            let afterEventAttached  = false
            function attachAfterEvent(server) {
                if (attachAfterEvent ) return
                afterEventAttached  = true
                server.on('after', function (req, res) {
                    if (res.statusCode == 200 && res._body != null) {
                        const urlKey = crpto.createHash('sha512')
                            .update(req.url)
                            .digets()
                            .toString('hex')
                        const contentHash = crypto.createHash('sha512')
                        .update(JSON.stringify(res._body))
                        .digest()
                        .toString('hex')
                        if (cache[urlKey] != contentHash) cache[urlKey] = contentHash
                    }
                })
            }
             return function(req, res, next) {
                    // 译者注: 这里attachEvent的位置好像不太优雅,我换另一种方式改了下这里。可以参考: https://github.com/cuiyongjian/study-restify/tree/master/app
                    attachAfterEvent(this) // 给server注册一个after钩子,每次即将响应数据时去计算body的etag值
                const urlKey = crypto.createHash('sha512')
                .update(req.url)
                .digest()
                .toString('hex')
                // 译者注: 这里etag的返回逻辑应该有点小问题,每次请求都是返回的上次写入cache的etag
                if (urlKey in cache) res.set('Etag', cache[urlKey])
                res.set('Cache-Control', 'public; max-age=120')
            }
        }
        return { fetchContent, timestamp, etagger }
    }

    务必不要用这段代码作为最佳实践,因为这里面有很多代码的坏味道,但是我们接下来将测量并找出这些问题。

    要获得这个例子的源码可以去 这里

    Profiling 剖析

    为了剖析我们的代码,我们需要两个终端窗口。一个用来启动app,另外一个用来压测他。

    第一个terminal,我们执行:

    node ./index.js

    另外一个terminal,我们这样剖析他(译者注: 实际是在压测):

    autocannon -c100 localhost:3000/seed/v1

    这将打开100个并发请求轰炸服务,持续10秒。

    结果大概是下面这个样子:

    statavgstdevMax
    耗时(毫秒)3086.811725.25554
    吞吐量(请求/秒)23.119.1865
    每秒传输量(字节/秒)237.98 kB197.7 kB688.13 kB
    231 requests in 10s, 2.4 MB read

    结果会根据你机器情况变化。然而我们知道: 一般的“Hello World”Node.js服务器很容易在同样的机器上每秒完成三万个请求,现在这段代码只能承受每秒23个请求且平均延迟超过3秒,这是令人沮丧的。

    译者注: 我用公司macpro18款 15寸 16G 256G,测试结果如下:

    图片描述

    诊断

    定位问题

    我们可以通过一句命令来诊断应用,感谢 clinic doctor 的 -on-port 命令。在app目录下,我们执行:

    clinic doctor --on-port='autocannon -c100 localhost:3000/seed/v1' -- node index.js
    译者注:
    现在autocannon的话可以使用新的subarg形式的命令语法:
    clinic doctor --autocannon [ /seed/v1 -c 100 ] -- node index.js

    clinic doctor会在剖析完毕后,创建html文件并自动打开浏览器。

    结果长这个样子:

    图片描述

    译者的测试长这样子:
    --on-port语法

    --autocannon语法

    译者注:横坐标其实是你系统时间,冒号后面的表示当前的系统时间的 - 秒数。
    备注:接下来的文章内容分析,我们还是以原文的统计结果图片为依据。

    跟随UI顶部的消息,我们看到 EventLoop 图表,它的确是红色的,并且这个EventLoop延迟在持续增长。在我们深入研究他意味着什么之前,我们先了解下其他指标下的诊断。

    我们可以看到CPU一直在100%或超过100%这里徘徊,因为进程正在努力处理排队的请求。Node的 JavaScript 引擎(也就是V8) 着这里实际上用 2 个 CPU核心在工作,因为机器是多核的 而V8会用2个线程。 一个线程用来执行 EventLoop,另外一个线程用来垃圾收集。 当CPU高达120%的时候就是进程在回收处理完的请求的遗留对象了(译者注: 操作系统的进程CPU使用率的确经常会超过100%,这是因为进程内用了多线程,OS把工作分配到了多个核心,因此统计cpu占用时间时会超过100%)

    我们看与之相关的内存图表。实线表示内存的堆内存占用(译者注:RSS表示node进程实际占用的内存,heapUsage堆内存占用就是指的堆区域占用了多少,THA就表示总共申请到了多少堆内存。一般看heapUsage就好,因为他表示了node代码中大多数JavaScript对象所占用的内存)。我们看到,只要CPU图表上升一下则堆内存占用就下降一些,这表示内存正在被回收。

    activeHandler跟EventLoop的延迟没有什么相关性。一个active hanlder 就是一个表达 I/O的对象(比如socket或文件句柄) 或者一个timer (比如setInterval)。我们用autocannon创建了100连接的请求(-c100), activehandlers 保持在103. 额外的3个handler其实是 STDOUT,STDERROR 以及 server 对象自身(译者: server自身也是个socket监听句柄)。

    如果我们点击一下UI界面上底部的建议pannel面板,我们会看到:
    图片描述

    短期缓解

    深入分析性能问题需要花费大量的时间。在一个现网项目中,可以给服务器或服务添加过载保护。过载保护的思路就是检测 EventLoop 延迟(以及其他指标),然后在超过阈值时响应一个 "503 Service Unavailable"。这就可以让 负载均衡器转向其他server实例,或者实在不行就让用户过一会重试。 overload-protection-module这个过载保护模块能直接低成本地接入到 Express、Koa 和 Restify使用。Hapi 框架也有一个 配置项提供同样的过载保护。(译者注:实际上看overload-protection模块的底层就是通过 loopbench实现的EventLoop延迟采样,而loopbench就是从Hapi框架里抽离出来的一个模块;至于内存占用,则是overload-protection内部自己实现的采样,毕竟直接用memoryUsage的api就好了)

    理解问题所在

    就像 Clinic Doctor 说的,如果 EventLoop 延迟到我们观察的这个样子,很可能有一个或多个函数阻塞了事件循环。认识到Node.js的这个主要特性非常重要:在当前的同步代码执行完成之前,异步事件是无法被执行的。这就是为什么下面 setTimeout 不能按照预料的时间触发的原因。

    举例,在浏览器开发者工具或Node.js的REPL里面执行:

    console.time('timeout')
    setTimeout(console.timeEnd, 100, 'timeout')
    let n = 1e7
    while (n--) Math.random()

    这个打印出的时间永远不会是100ms。它将是150ms到250ms之间的一个数字。 setTimeoiut调度了一个异步操作(console.timeEnd),但是当前执行的代码没有完成;下面有额外两行代码来做了一个循环。当前所执行的代码通常被叫做“Tick”。要完成这个 Tick,Math.random 需要被调用 1000 万次。如果这会花销 100ms,那么timeout触发时的总时间就是 200ms (再加上setTimeout函数实际推入队列时的延时,约几毫秒)

    译者注: 实际上这里作者的解释有点小问题。首先这个例子假如按他所说循环会耗费100毫秒,那么setTimeout触发时也是100ms而已,不会是两个时间相加。因为100毫秒的循环结束,setTimeout也要被触发了。
    另外:你实际电脑测试时,很可能像我一样得到的结果是 100ms多一点,而不是作者的150-250之间。作者之所以得到 150ms,是因为它使用的电脑性能原因使得 while(n--) 这个循环所花费的时间是 150ms到250ms。而一旦性能好一点的电脑计算1e7次循环只需几十毫秒,完全不会阻塞100毫秒之后的setTimeout,这时得到的结果往往是103ms左右,其中的3ms是底层函数入队和调用花掉的时间(跟这里所说的问题无关)。因此,你自己在测试时可以把1e7改成1e8试试。总之让他的执行时间超过100毫秒。

    在服务器端上下文如果一个操作在当前 Tick 中执行时间很长,那么就会导致请求无法被处理,并且数据也无法获取(译者注:比如处理新的网络请求或处理读取文件的IO事件),因为异步代码在当前 Tick 完成之前无法执行。这意味着计算昂贵的代码将会让server所有交互都变得缓慢。所以建议你拆分资源敏感的任务到单独的进程里去,然后从main主server中去调用它,这能避免那些很少使用但资源敏感(译者注: 这里特指CPU敏感)的路由拖慢了那些经常访问但资源不敏感的路由的性能(译者注:就是不要让某个cpu密集的路径拖慢整个node应用)。

    本文的例子server中有很多代码阻塞了事件循环,所以下一步我们来定位这个代码的具体位置所在。

    分析

    定位性能问题的代码的一个方法就是创建和分析“火焰图”。一个火焰图将函数表达为彼此叠加的块---不是随着时间的推移而是聚合。之所以叫火焰图是因为它用橘黄到红色的色阶来表示,越红的块则表示是个“热点”函数,意味着很可能会阻塞事件循环。获取火焰图的数据需要通过对CPU进行采样---即node中当前执行的函数及其堆栈的快照。而热量(heat)是由一个函数在分析期间处于栈顶执行所占用的时间百分比决定的。如果它不是当前栈中最后被调用的那个函数,那么他就很可能会阻塞事件循环。

    让我们用 clinic flame来生成示例代码的火焰图:

    clinic flame --on-port=’autocannon -c100 localhost:$PORT/seed/v1’ -- node index.js
    译者注: 也可以使用新版命令风格:
    clinic flame --autocannon [ /seed/v1 -c200 -d 10 ] -- node index.js

    结果会自动展示在你的浏览器中:

    Clinic可视化火焰图

    译者注: 新版变成下面这副样子了,功能更强大,但可能得学习下怎么看。。

    图片描述

    (译者注:下面分析时还是看原文的图)
    块的宽度表示它花费了多少CPU时间。可以看到3个主要堆栈花费了大部分的时间,而其中 server.on这个是最红的。 实际上,这3个堆栈是相同的。他们之所以分开是因为在分析期间优化过的和未优化的函数会被视为不同的调用帧。带有 *前缀的是被JavaScript引擎优化过的函数,而带有 ~前缀的是未优化的。如果是否优化对我们的分析不重要,我们可以点击 Merge按钮把它们合并。这时图像会变成这样:

    图片描述

    从开始看,我们可以发现出问题的代码在 util.js里。这个过慢的函数也是一个 event handler:触发这个函数的来源是Node核心里的 events模块,而 server.on是event handler匿名函数的一个后备名称。我们可以看到这个代码跟实际处理本次request请求的代码并不在同一个 Tick 当中(译者注: 如果在同一个Tick就会用一个堆栈图竖向堆叠起来)。如果跟request处理在同一个 Tick中,那堆栈中应该是Node的 http模块、net和stream模块

    如果你展开其他的更小的块你会看到这些Http的Node核心函数。比如尝试下右上角的search,搜索关键词 send(restify和http内部方法都有send方法)。然后你可以发现他们在火焰图的右边(函数按字母排序)(译者注:右侧蓝色高亮的区域)

    搜索http处理函数

    可以看到实际的 HTTP 处理块占用时间相对较少。

    我们可以点击一个高亮的青色块来展开,看到里面 http_outgoing.js文件的 writeHead、write函数(Node核心http库中的一部分)

    图片描述

    我们可以点击 all stack返回到主要视图。

    这里的关键点是,尽管 server.on函数跟实际 request处理代码不在一个 Tick中,它依然能通过延迟其他正在执行的代码来影响了server的性能。

    Debuging 调试

    我们现在从火焰图知道了问题函数在 util.js 的 server.on这个eventHandler里。我们来瞅一眼:

    server.on('after', (req, res) => {
      if (res.statusCode !== 200) return
      if (!res._body) return
      const key = crypto.createHash('sha512')
        .update(req.url)
        .digest()
        .toString('hex')
      const etag = crypto.createHash('sha512')
        .update(JSON.stringify(res._body))
        .digest()
        .toString('hex')
      if (cache[key] !== etag) cache[key] = etag
    })

    众所周知,加密过程都是很昂贵的cpu密集任务,还有序列化(JSON.stringify),但是为什么火焰图中看不到呢?实际上在采样过程中都已经被记录了,只是他们隐藏在 cpp过滤器内 (译者注:cpp就是c++类型的代码)。我们点击 cpp 按钮就能看到如下的样子:

    解开序列化和加密的图

    与序列化和加密相关的内部V8指令被展示为最热的区域堆栈,并且花费了最多的时间。 JSON.stringify方法直接调用了 C++代码,这就是为什么我们看不到JavaScript 函数。在加密这里, createHashupdate这样的函数都在数据中,而他们要么内联(合并并消失在merge视图)要么占用时间太小无法展示。

    一旦我们开始推理etagger函数中的代码,很快就会发现它的设计很糟糕。为什么我们要从函数上下文中获取服务器实例?所有这些hash计算都是必要的吗?在实际场景中也没有If-None-Match头支持,如果用if-none-match这将减轻某些真实场景中的一些负载,因为客户端会发出头请求来确定资源的新鲜度。

    让我们先忽略所有这些问题,先验证一下 server.on中的代码是否是导致问题的原因。我们可以把 server.on里面的代码做成空函数然后生成一个新的火焰图。

    现在 etagger 函数变成这样:

    function etagger () {
      var cache = {}
      var afterEventAttached = false
      function attachAfterEvent (server) {
        if (attachAfterEvent === true) return
        afterEventAttached = true
        server.on('after', (req, res) => {})
      }
      return function (req, res, next) {
        attachAfterEvent(this)
        const key = crypto.createHash('sha512')
          .update(req.url)
          .digest()
          .toString('hex')
        if (key in cache) res.set('Etag', cache[key])
        res.set('Cache-Control', 'public, max-age=120')
        next()
      }
    }

    现在 server.on的事件监听函数是个以空函数 no-op. 让我们再次执行 clinic flame:

    clinic flame --on-port='autocannon -c100 localhost:$PORT/seed/v1' -- node index.js
    Copy

    会生成如下的火焰图:
    server.on为空函数后的火焰图

    这看起来好一些,我们会看到每秒吞吐量有所增长。但是为什么 event emit 的代码这么红? 我们期望的是此时 HTTP 处理要占用最多的CPU时间,毕竟 server.on 里面已经什么都没做了。

    这种类型的瓶颈通常因为一个函数调用超出了一定期望的程度。

    util.js顶部的这一句可疑的代码可能是一个线索:

    require('events').defaultMaxListeners = Infinity

    让我们移除掉这句代码,然后启动我们的应用,带上 --trace-warnings flag标记。

    node --trace-warnings index.js

    如果我们在下一个teminal中执行压测:

    autocannon -c100 localhost:3000/seed/v1

    会看到我们的进程输出一些:

    (node:96371) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 after listeners added. Use emitter.setMaxListeners() to increase limit
      at _addListener (events.js:280:19)
      at Server.addListener (events.js:297:10)
      at attachAfterEvent 
        (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:22:14)
      at Server.
        (/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:25:7)
      at call
        (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:164:9)
      at next
        (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:120:9)
      at Chain.run
        (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:123:5)
      at Server._runUse
        (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:976:19)
      at Server._runRoute
        (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:918:10)
      at Server._afterPre
        (/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:888:10)

    Node 告诉我们有太多的事件添加到了 server 对象上。这很奇怪,因为我们有一句判断,如果 after事件已经绑定到了 server,则直接return。所以首次绑定之后,只有一个 no-op 函数绑到了 server上。

    让我们看下 attachAfterEvent函数:

    var afterEventAttached = false
    function attachAfterEvent (server) {
      if (attachAfterEvent === true) return
      afterEventAttached = true
      server.on('after', (req, res) => {})
    }

    我们发现条件检查语句写错了! 不应该是 attachAfterEvent ,而是 afterEventAttached. 这意味着每个请求都会往 server 对象上添加一个事件监听,然后每个请求的最后所有的之前绑定上的事件都要触发。唉呀妈呀!

    优化

    既然知道了问题所在,让我们看看如何让我们的server更快

    低端优化 (容易摘到的果子)

    让我们还原 server.on 的代码(不让他是空函数了)然后条件语句中改成正确的 boolean 判断。现在我们的 etagger 函数这样:

    function etagger () {
      var cache = {}
      var afterEventAttached = false
      function attachAfterEvent (server) {
        if (afterEventAttached === true) return
        afterEventAttached = true
        server.on('after', (req, res) => {
          if (res.statusCode !== 200) return
          if (!res._body) return
          const key = crypto.createHash('sha512')
            .update(req.url)
            .digest()
            .toString('hex')
          const etag = crypto.createHash('sha512')
            .update(JSON.stringify(res._body))
            .digest()
            .toString('hex')
          if (cache[key] !== etag) cache[key] = etag
        })
      }
      return function (req, res, next) {
        attachAfterEvent(this)
        const key = crypto.createHash('sha512')
          .update(req.url)
          .digest()
          .toString('hex')
        if (key in cache) res.set('Etag', cache[key])
        res.set('Cache-Control', 'public, max-age=120')
        next()
      }
    }

    现在,我们再来执行一次 Profile(进程剖析,进程描述)。

    node index.js

    然后用 autocanno 来profile 它:

    autocannon -c100 localhost:3000/seed/v1

    我们看到结果显示有200倍的提升(持续10秒 100个并发)

    图片描述

    平衡开发成本和潜在的服务器成本也非常重要。我们需要定义我们在优化时要走多远。否则我们很容易将80%的时间投入到20%的性能提高上。项目是否能承受?

    在一些场景下,用 低端优化来花费一天提高200倍速度才被认为是合理的。而在某些情况下,我们可能希望不惜一切让我们的项目尽最大最大最大可能的快。这种抉择要取决于项目优先级。

    控制资源支出的一种方法是设定目标。例如,提高10倍,或达到每秒4000次请求。基于业务需求的这一种方式最有意义。例如,如果服务器成本超出预算100%,我们可以设定2倍改进的目标

    更进一步

    如果我们再做一张火焰图,我们会看到:

    图片描述

    事件监听器依然是一个瓶颈,它依然占用了 1/3 的CPU时间 (它的宽度大约是整行的三分之一)

    (译者注: 在做优化之前可能每次都要做这样的思考:) 通过优化我们能获得哪些额外收益,以及这些改变(包括相关联的代码重构)是否值得?

    ==============

    我们看最终终极优化(译者注:终极优化指的是作者在后文提到的另外一些方法)后能达到的性能特征(持续执行十秒 http://localhost:3000/seed/v1 --- 100个并发连接)

    92k requests in 11s, 937.22 MB read[15]

    尽管终极优化后 1.6倍 的性能提高已经很显著了,但与之付出的努力、改变、代码重构 是否有必要也是值得商榷的。尤其是与之前简单修复一个bug就能提升200倍的性能相比。

    为了实现深度改进,需要使用同样的技术如:profile分析、生成火焰图、分析、debug、优化。最后完成优化后的服务器代码,可以在 这里查看。

    最后提高到 800/s 的吞吐量,使用了如下方法:

    这些更改稍微复杂一些,对代码库的破坏性稍大一些,并使etagger中间件的灵活性稍微降低,因为它会给路由带来负担以提供Etag值。但它在执行Profile的机器上每秒可多增加3000个请求。

    让我们看看最终优化后的火焰图:

    所有优化之后的健康火焰图

    图中最热点的地方是 Node core(node核心)的 net 模块。这是最期望的情况。

    防止性能问题

    完美一点,这里提供一些在部署之前防止性能问题的建议。

    在开发期间使用性能工具作为非正式检查点可以避免把性能问题带入生产环境。建议将AutoCannon和Clinic(或其他类似的工具)作为日常开发工具的一部分。

    购买或使用一个框架时,看看他的性能政策是什么(译者注:对开源框架就看看benchmark和文档中的性能建议)。如果框架没有指出性能相关的,那么就看看他是否与你的基础架构和业务目标一致。例如,Restify已明确(自版本7发布以来)将致力于提升性。但是,如果低成本和高速度是你绝对优先考虑的问题,请考虑使用 Fastify,Restify贡献者测得的速度提高17%。

    在选择一些广泛流行的类库时要多加留意---尤其是留意日志。 在开发者修复issue的时候,他们可能会在代码中添加一些日志输出来帮助他们在未来debug问题。如果她用了一个性能差劲的 logger 组件,这可能会像 温水煮青蛙一样随着时间的推移扼杀性能。 pino日志组件是一个 Node.js 中可以用的速度最快的JSON换行日志组件。

    最后,始终记住Event Loop是一个共享资源。 Node.js服务器的性能会受到最热路径中最慢的那个逻辑的约束。

    为什么我们要熟悉这些通信协议? 【精读】

    $
    0
    0

    clipboard.png

    前端的最重要的基础知识点是什么?

    • 原生 javaScriptHTML, CSS.
    • Dom操作
    • EventLoop和渲染机制
    • 各类工程化的工具原理以及使用,根据需求定制编写插件和包。(webpack的plugin和babel的预设包)
    • 数据结构和算法(特别是 IM以及超大型高并发网站应用等,例如 B站
    • 最后便是通信协议
    在使用某个技术的时候,一定要去追寻原理和底层的实现,长此以往坚持,只要自身底层的基础扎实,无论技术怎么变化,学习起来都不会太累,总的来说就是拒绝5分钟技术

    从输入一个 url地址,到显示页面发生了什么出发:

    • 1.浏览器向 DNS 服务器请求解析该 URL 中的域名所对应的 IP 地址;
    • 2.建立TCP连接(三次握手);
    • 3.浏览器发出读取文件(URL 中域名后面部分对应的文件)的HTTP 请求,该请求报文作为 TCP 三次握手的第三个报文的数据发送给服务器;
    • 4.服务器对浏览器请求作出响应,并把对应的 html 文本发送给浏览器;
    • 5.浏览器将该 html 文本并显示内容;
    • 6.释放 TCP连接(四次挥手);

    目前常见的通信协议都是建立在 TCP链接之上

    那么什么是 TCP

    TCP是因特网中的传输层协议,使用三次握手协议建立连接。当主动方发出SYN连接请求后,等待对方回答

    TCP三次握手的过程如下:
    • 客户端发送 SYN报文给服务器端,进入 SYN_SEND状态。
    • 服务器端收到 SYN报文,回应一个 SYN(SEQ=y)ACK(ACK=x+1)报文,进入SYN_RECV状态。
    • 客户端收到服务器端的SYN报文,回应一个ACK(ACK=y+1)报文,进入Established状态。
    • 三次握手完成,TCP客户端和服务器端成功地建立连接,可以开始传输数据了。
    如图所示:

    clipboard.png

    TCP的四次挥手:
    • 建立一个连接需要三次握手,而终止一个连接要经过四次握手,这是由TCP的半关闭(half-close)造成的。具体过程如下图所示。
    • 某个应用进程首先调用close,称该端执行“主动关闭”(active close)。该端的TCP于是发送一个FIN分节,表示数据发送完毕。
    • 接收到这个FIN的对端执行 “被动关闭”(passive close),这个FIN由TCP确认。

    注意:FIN的接收也作为一个文件结束符(end-of-file)传递给接收端应用进程,放在已排队等候该应用进程接收的任何其他数据之后,因为,FIN的接收意味着接收端应用进程在相应连接上再无额外数据可接收。

    • 一段时间后,接收到这个文件结束符的应用进程将调用close关闭它的套接字。这导致它的TCP也发送一个FIN。
    • 接收这个最终FIN的原发送端TCP(即执行主动关闭的那一端)确认这个FIN。 [3]

    既然每个方向都需要一个FIN和一个ACK,因此通常需要4个分节。

    特别提示: SYN报文用来通知, FIN报文是用来同步的

    clipboard.png

    以上就是面试官常问的三次握手,四次挥手,但是这不仅仅面试题,上面仅仅答到了一点皮毛,学习这些是为了让我们后续方便了解他的优缺点。

    TCP连接建立后,我们可以有多种协议的方式通信交换数据:

    最古老的方式一: http 1.0

    • 早先1.0的HTTP版本,是一种无状态、无连接的应用层协议。
    • HTTP1.0规定浏览器和服务器保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接,服务器处理完成后立即断开TCP连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态)。
    • 这种无状态性可以借助cookie/session机制来做身份认证和状态记录。而下面两个问题就比较麻烦了。
    • 首先,无连接的特性导致最大的性能缺陷就是无法复用连接。每次发送请求的时候,都需要进行一次TCP的连接,而TCP的连接释放过程又是比较费事的。这种无连接的特性会使得网络的利用率非常低。
    • 其次就是队头阻塞(headoflineblocking)。由于HTTP1.0规定下一个请求必须在前一个请求响应到达之前才能发送。假设前一个请求响应一直不到达,那么下一个请求就不发送,同样的后面的请求也给阻塞了。
    Http 1.0的致命缺点,就是无法复用 TCP连接和并行发送请求,这样每次一个请求都需要三次握手,而且其实建立连接和释放连接的这个过程是最耗时的,传输数据相反却不那么耗时。还有本地时间被修改导致响应头 expires的缓存机制失效的问题~(后面会详细讲)
    • 常见的请求报文~

    clipboard.png

    于是出现了 Http 1.1,这也是技术的发展必然结果~

    • Http 1.1出现,继承了 Http1.0的优点,也克服了它的缺点,出现了 keep-alive这个头部字段,它表示会在建立 TCP连接后,完成首次的请求,并不会立刻断开 TCP连接,而是保持这个连接状态~进而可以复用这个通道
    • Http 1.1并且支持请求管道化,“并行”发送请求,但是这个并行,也不是真正意义上的并行,而是可以让我们把先进先出队列从客户端(请求队列)迁移到服务端(响应队列)
    例如:客户端同时发了两个请求分别来获取html和css,假如说服务器的css资源先准备就绪,服务器也会先发送html再发送css。
    • B站首页,就有 keep-alive,因为他们也有 IM的成分在里面。需要大量复用 TCP连接~

    clipboard.png

    • HTTP1.1好像还是无法解决队头阻塞的问题
    实际上,现阶段的浏览器厂商采取了另外一种做法,它允许我们打开多个TCP的会话。也就是说,上图我们看到的并行,其实是不同的TCP连接上的HTTP请求和响应。这也就是我们所熟悉的浏览器对同域下并行加载6~8个资源的限制。而这,才是真正的并行!

    Http 1.1的致命缺点:

    • 1.明文传输
    • 2.其实还是没有解决无状态连接的
    • 3.当有多个请求同时被挂起的时候 就会拥塞请求通道,导致后面请求无法发送
    • 4.臃肿的消息首部:HTTP/1.1能压缩请求内容,但是消息首部不能压缩;在现今请求中,消息首部占请求绝大部分(甚至是全部)也较为常见.
    我们也可以用 dns-prefetch和 preconnect tcp来优化~
    <link rel="preconnect" href="//example.com" crossorigin><link rel="dns=prefetch" href="//example.com">
    • Tip: webpack可以做任何事情,这些都可以用插件实现

    基于这些缺点,出现了 Http 2.0

    相较于HTTP1.1,HTTP2.0的主要优点有采用二进制帧封装,传输变成多路复用,流量控制算法优化,服务器端推送,首部压缩,优先级等特点。

    HTTP1.x的解析是基于文本的,基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多。而HTTP/2会将所有传输的信息分割为更小的消息和帧,然后采用二进制的格式进行编码,HTTP1.x的头部信息会被封装到HEADER frame,而相应的RequestBody则封装到DATAframe里面。不改动HTTP的语义,使用二进制编码,实现方便且健壮。

    多路复用

    • 所有的请求都是通过一个 TCP 连接并发完成。HTTP/1.x 虽然通过 pipeline 也能并发请求,但是多个请求之间的响应会被阻塞的,所以 pipeline 至今也没有被普及应用,而 HTTP/2 做到了真正的并发请求。同时,流还支持优先级和流量控制。当流并发时,就会涉及到流的优先级和依赖。即:HTTP2.0对于同一域名下所有请求都是基于流的,不管对于同一域名访问多少文件,也只建立一路连接。优先级高的流会被优先发送。图片请求的优先级要低于 CSS 和 SCRIPT,这个设计可以确保重要的东西可以被优先加载完

    流量控制

    • TCP协议通过sliding window的算法来做流量控制。发送方有个sending window,接收方有receive window。http2.0的flow control是类似receive window的做法,数据的接收方通过告知对方自己的flow window大小表明自己还能接收多少数据。只有Data类型的frame才有flow control的功能。对于flow control,如果接收方在flow window为零的情况下依然更多的frame,则会返回block类型的frame,这张场景一般表明http2.0的部署出了问题。

    服务器端推送

    • 服务器端的推送,就是服务器可以对一个客户端请求发送多个响应。除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。当浏览器请求一个html,服务器其实大概知道你是接下来要请求资源了,而不需要等待浏览器得到html后解析页面再发送资源请求。

    首部压缩

    • HTTP 2.0 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送;通信期间几乎不会改变的通用键-值对(用户代理、可接受的媒体类型,等等)只 需发送一次。事实上,如果请求中不包含首部(例如对同一资源的轮询请求),那么 首部开销就是零字节。此时所有首部都自动使用之前请求发送的首部。
    • 如果首部发生变化了,那么只需要发送变化了数据在Headers帧里面,新增或修改的首部帧会被追加到“首部表”。首部表在 HTTP 2.0 的连接存续期内始终存在,由客户端和服务器共同渐进地更新 。
    • 本质上,当然是为了减少请求啦,通过多个js或css合并成一个文件,多张小图片拼合成Sprite图,可以让多个HTTP请求减少为一个,减少额外的协议开销,而提升性能。当然,一个HTTP的请求的body太大也是不合理的,有个度。文件的合并也会牺牲模块化和缓存粒度,可以把“稳定”的代码or 小图 合并为一个文件or一张Sprite,让其充分地缓存起来,从而区分开迭代快的文件。
    Demo的性能对比:

    clipboard.png

    Http的那些致命缺陷,并没有完全解决,于是有了 https,也是目前应用最广的协议之一

    HTTP+ 加密 + 认证 + 完整性保护 =HTTPS ?

    可以这样认为~HTTP 加上加密处理和认证以及完整性保护后即是 HTTPS

    • 如果在 HTTP 协议通信过程中使用未经加密的明文,比如在 Web 页面中输入信用卡号,如果这条通信线路遭到窃听,那么信用卡号就暴露了。
    • 另外,对于 HTTP 来说,服务器也好,客户端也好,都是没有办法确认通信方的。

    因为很有可能并不是和原本预想的通信方在实际通信。并且还需要考虑到接收到的报文在通信途中已经遭到篡改这一可能性。

    • 为了统一解决上述这些问题,需要在 HTTP 上再加入加密处理和认证等机制。我们把添加了加密及认证机制的 HTTP 称为 HTTPS
    不加密的重要内容被 wireshark这类工具抓到包,后果很严重~

    HTTPS 是身披 SSL 外壳的 HTTP

    • HTTPS 并非是应用层的一种新协议。只是 HTTP 通信接口部分用 SSL(SecureSocket Layer)和 TLS(Transport Layer Security)协议代替而已。

    通常,HTTP 直接和 TCP 通信。

    • 当使用 SSL 时,则演变成先和 SSL 通信,再由 SSL和 TCP 通信了。简言之,所谓 HTTPS,其实就是身披 SSL 协议这层外壳的HTTP。
    • 在采用 SSL 后,HTTP 就拥有了 HTTPS 的加密、证书和完整性保护这些功能。SSL 是独立于 HTTP 的协议,所以不光是 HTTP 协议,其他运行在应用层的 SMTP和 Telnet 等协议均可配合 SSL 协议使用。可以说 SSL 是当今世界上应用最为广泛的网络安全术。

    clipboard.png

    相互交换密钥的公开密钥加密技术 -----对称加密

    clipboard.png

    • 在对 SSL 进行讲解之前,我们先来了解一下加密方法。SSL 采用一种叫做公开密钥加密(Public-key cryptography)的加密处理方式。
    • 近代的加密方法中加密算法是公开的,而密钥却是保密的。通过这种方式得以保持加密方法的安全性。
    加密和解密都会用到密钥。没有密钥就无法对密码解密,反过来说,任何人只要持有密钥就能解密了。如果密钥被攻击者获得,那加密也就失去了意义。

    HTTPS 采用混合加密机制

    • HTTPS 采用共享密钥加密和公开密钥加密两者并用的混合加密机制。
    • 但是公开密钥加密与共享密钥加密相比,其处理速度要慢。所以应充分利用两者各自的优势,将多种方法组合起来用于通信。在交换密钥环节使用公开密钥加密方式,之后的建立通信交换报文阶段则使用共享密钥加密方式。

    HTTPS虽好,非对称加密虽好,但是不要滥用

    HTTPS 也存在一些问题,那就是当使用 SSL 时,它的处理速度会变慢。

    SSL 的慢分两种。一种是指通信慢。另一种是指由于大量消耗 CPU 及内存等资源,导致处理速度变慢。

    • 和使用 HTTP 相比,网络负载可能会变慢 2 到 100 倍。除去和 TCP 连接、发送 HTTP 请求 ? 响应以外,还必须进行 SSL 通信,因此整体上处理通信量不可避免会增加。
    • 另一点是 SSL 必须进行加密处理。在服务器和客户端都需要进行加密和解密的运算处理。因此从结果上讲,比起 HTTP 会更多地消耗服务器和客户端的硬件资源,导致负载增强。

    针对速度变慢这一问题,并没有根本性的解决方案,我们会使用 SSL 加速器这种(专用服务器)硬件来改善该问题。该硬件为 SSL 通信专用硬件,相对软件来讲,能够提高数倍 SSL 的计算速度。仅在 SSL 处理时发挥 SSL加速器的功效,以分担负载。

    为什么不一直使用 HTTPS

    • 既然 HTTPS 那么安全可靠,那为何所有的 Web 网站不一直使用 HTTPS?

    其中一个原因是,因为与纯文本通信相比,加密通信会消耗更多的 CPU 及内存资源。如果每次通信都加密,会消耗相当多的资源,平摊到一台计算机上时,能够处理的请求数量必定也会随之减少。

    • 因此,如果是非敏感信息则使用 HTTP 通信,只有在包含个人信息等敏感数据时,才利用 HTTPS 加密通信。

    特别是每当那些访问量较多的 Web 网站在进行加密处理时,它们所承担着的负载不容小觑。在进行加密处理时,并非对所有内容都进行加密处理,而是仅在那些需要信息隐藏时才会加密,以节约资源。

    • 除此之外,想要节约购买证书的开销也是原因之一。

    要进行 HTTPS 通信,证书是必不可少的。而使用的证书必须向认证机构(CA)购买。证书价格可能会根据不同的认证机构略有不同。通常,一年的授权需要数万日元(现在一万日元大约折合 600 人民币)。那些购买证书并不合算的服务以及一些个人网站,可能只会选择采用HTTP 的通信方式。

    clipboard.png

    复习完了基本的协议,介绍下报文格式:

    • 请求报文格式

    clipboard.png

    • 响应报文格式

    clipboard.png

    所谓响应头,请求头,其实都可以自己添加字段,只要前后端给对应的处理机制即可

    Node.js代码实现响应头的设置

    
      if (config.cache.expires) {
                            res.setHeader("expries", new Date(Date.now() + (config.cache.maxAge * 1000)))
                        }
                        if (config.cache.lastModified) {
                            res.setHeader("last-modified", stat.mtime.toUTCString())
                        }
                        if (config.cache.etag) {
                            res.setHeader('Etag', etagFn(stat))
                        }
    }

    响应头的详解:

    clipboard.png

    本人的开源项目,手写的 Node.js静态资源服务器, https://github.com/JinJieTan/...,欢迎 star~

    浏览器的缓存策略:

    • 首次请求:

    clipboard.png

    • 非首次请求:

    clipboard.png

    • 用户行为与缓存:

    clipboard.png

    不能缓存的请求:

    无法被浏览器缓存的请求如下:

    • HTTP信息头中包含Cache-Control:no-cache,pragma:no-cache(HTTP1.0),或Cache-Control:max-age=0等告诉浏览器不用缓存的请求
    • 需要根据Cookie,认证信息等决定输入内容的动态请求是不能被缓存的
    • 经过HTTPS安全加密的请求(有人也经过测试发现,ie其实在头部加入Cache-Control:max-age信息,firefox在头部加入Cache-Control:Public之后,能够对HTTPS的资源进行缓寸)
    • 经过HTTPS安全加密的请求(有人也经过测试发现,ie其实在头部加入Cache-Control:max-age信息,firefox在头部加入Cache-Control:Public之后,能够对HTTPS的资源进行缓存,参考《HTTPS的七个误解》)
    • POST请求无法被缓存
    • HTTP响应头中不包含Last-Modified/Etag,也不包含Cache-Control/Expires的请求无法被缓存

    即时通讯协议

    从最初的没有 websocket协议开始:

    传统的协议无法服务端主动 push数据,于是有了这些骚操作:
    • 轮询,在一个定时器中不停向服务端发送请求。
    • 长轮询,发送请求给服务端,直到服务端觉得可以返回数据了再返回响应,否则这个请求一直挂起~
    • 以上两种都有瑕疵,而且比较明显,这里不再描述。

    为了解决实时通讯,数据同步的问题,出现了 webSocket.

    • webSockets的目标是在一个单独的持久连接上提供全双工、双向通信。在Javascript创建了Web Socket之后,会有一个HTTP请求发送到浏览器以发起连接。在取得服务器响应后,建立的连接会将HTTP升级从HTTP协议交换为WebSocket协议。
    • webSocket原理: 在 TCP连接第一次握手的时候,升级为 ws协议。后面的数据交互都复用这个 TCP通道。
    • 客户端代码实现:
      const ws = new WebSocket('ws://localhost:8080');
            ws.onopen = function () {
                ws.send('123')
                console.log('open')
            }
            ws.onmessage = function () {
                console.log('onmessage')
            }
            ws.onerror = function () {
                console.log('onerror')
            }
            ws.onclose = function () {
                console.log('onclose')
            }
    • 服务端使用 Node.js语言实现
    const express = require('express')
    const { Server } = require("ws");
    const app = express()
    const wsServer = new Server({ port: 8080 })
    wsServer.on('connection', (ws) => {
        ws.onopen = function () {
            console.log('open')
        }
        ws.onmessage = function (data) {
            console.log(data)
            ws.send('234')
            console.log('onmessage' + data)
        }
        ws.onerror = function () {
            console.log('onerror')
        }
        ws.onclose = function () {
            console.log('onclose')
        }
    });
    
    app.listen(8000, (err) => {
        if (!err) { console.log('监听OK') } else {
            console.log('监听失败')
        }
    })

    webSocket的报文格式有一些不一样:

    ![图片上传中...]

    • 客户端和服务端进行Websocket消息传递是这样的:

      • 客户端:将消息切割成多个帧,并发送给服务端。
      • 服务端:接收消息帧,并将关联的帧重新组装成完整的消息。

    即时通讯的心跳检测:

    pingand pong

    • 服务端 Go实现:
    package main
    
    import (
        "net/http""time""github.com/gorilla/websocket"
    )
    
    var (
        //完成握手操作
        upgrade = websocket.Upgrader{
           //允许跨域(一般来讲,websocket都是独立部署的)
           CheckOrigin:func(r *http.Request) bool {
                return true
           },
        }
    )
    
    func wsHandler(w http.ResponseWriter, r *http.Request) {
       var (
             conn *websocket.Conn
             err error
             data []byte
       )
       //服务端对客户端的http请求(升级为websocket协议)进行应答,应答之后,协议升级为websocket,http建立连接时的tcp三次握手将保持。
       if conn, err = upgrade.Upgrade(w, r, nil); err != nil {
            return
       }
    
        //启动一个协程,每隔5s向客户端发送一次心跳消息
        go func() {
            var (
                err error
            )
            for {
                if err = conn.WriteMessage(websocket.TextMessage, []byte("heartbeat")); err != nil {
                    return
                }
                time.Sleep(5 * time.Second)
            }
        }()
    
       //得到websocket的长链接之后,就可以对客户端传递的数据进行操作了
       for {
             //通过websocket长链接读到的数据可以是text文本数据,也可以是二进制Binary
            if _, data, err = conn.ReadMessage(); err != nil {
                goto ERR
         }
         if err = conn.WriteMessage(websocket.TextMessage, data); err != nil {
             goto ERR
         }
       }
    ERR:
        //出错之后,关闭socket连接
        conn.Close()
    }
    
    func main() {
        http.HandleFunc("/ws", wsHandler)
        http.ListenAndServe("0.0.0.0:7777", nil)
    }

    客户端的心跳检测( Node.js实现):

    this.heartTimer = setInterval(() => {
          if (this.heartbeatLoss < MAXLOSSTIMES) {
            events.emit('network', 'sendHeart');
            this.heartbeatLoss += 1;
            this.phoneLoss += 1;
          } else {
            events.emit('network', 'offline');
            this.stop();
          }
          if (this.phoneLoss > MAXLOSSTIMES) {
            this.PhoneLive = false;
            events.emit('network', 'phoneDisconnect');
          }
        }, 5000);

    自定义即时通信协议:

    new Socket开始:

    • 目前即时通讯大都使用现有大公司成熟的 SDK接入,但是逼格高些还是自己重写比较好。
    • 打个小广告,我们公司就是自己定义的即时通讯协议~招聘一位高级前端,地点深圳-深南大道,做跨平台 IM桌面应用开发的~
    • 客户端代码实现(Node.js):
    
    const {Socket} = require('net') 
    const tcp = new Socket()
    tcp.setKeepAlive(true);
    tcp.setNoDelay(true);
    //保持底层tcp链接不断,长连接
    指定对应域名端口号链接
    tcp.connect(80,166.166.0.0)
    建立连接后
    根据后端传送的数据类型 使用对应不同的解析
    readUInt8 readUInt16LE readUInt32LE readIntLE等处理后得到myBuf 
    const myBuf = buffer.slice(start);//从对应的指针开始的位置截取buffer
    const header = myBuf.slice(headstart,headend)//截取对应的头部buffer
    const body = JSON.parse(myBuf.slice(headend-headstart,bodylength).tostring())
    //精确截取数据体的buffer,并且转化成js对象
    即时通讯强烈推荐使用 Golang, GRPC, Prob传输数据。

    上面的一些代码,都在我的开源项目中:

    觉得写得不错,可以点个赞支持下,文章也借鉴了一下其他大佬的文章,但是地址都贴上来了~ 欢迎 gitHub点个 star哦~

    Web Components 入门实例教程

    $
    0
    0

    组件是前端的发展方向,现在流行的 React 和 Vue 都是组件框架。

    谷歌公司由于掌握了 Chrome 浏览器,一直在推动浏览器的原生组件,即 Web Components API。相比第三方框架,原生组件简单直接,符合直觉,不用加载任何外部模块,代码量小。目前,它还在不断发展,但已经可用于生产环境。

    Web Components API 内容很多,本文不是全面的教程,只是一个简单演示,让大家看一下怎么用它开发组件。

    一、自定义元素

    下图是一个用户卡片。

    本文演示如何把这个卡片,写成 Web Components 组件,这里是最后的 完整代码

    网页只要插入下面的代码,就会显示用户卡片。

    <user-card></user-card>

    这种自定义的 HTML 标签,称为自定义元素(custom element)。根据规范,自定义元素的名称必须包含连词线,用与区别原生的 HTML 元素。所以, <user-card>不能写成 <usercard>

    二、 customElements.define()

    自定义元素需要使用 JavaScript 定义一个类,所有 <user-card>都会是这个类的实例。

    
    class UserCard extends HTMLElement {
      constructor() {
        super();
      }
    }

    上面代码中, UserCard就是自定义元素的类。注意,这个类的父类是 HTMLElement,因此继承了 HTML 元素的特性。

    接着,使用浏览器原生的 customElements.define()方法,告诉浏览器 <user-card>元素与这个类关联。

    
    window.customElements.define('user-card', UserCard);

    三、自定义元素的内容

    自定义元素 <user-card>目前还是空的,下面在类里面给出这个元素的内容。

    
    class UserCard extends HTMLElement {
      constructor() {
        super();
    
        var image = document.createElement('img');
        image.src = 'https://semantic-ui.com/images/avatar2/large/kristy.png';
        image.classList.add('image');
    
        var container = document.createElement('div');
        container.classList.add('container');
    
        var name = document.createElement('p');
        name.classList.add('name');
        name.innerText = 'User Name';
    
        var email = document.createElement('p');
        email.classList.add('email');
        email.innerText = 'yourmail@some-email.com';
    
        var button = document.createElement('button');
        button.classList.add('button');
        button.innerText = 'Follow';
    
        container.append(name, email, button);
        this.append(image, container);
      }
    }
    

    上面代码最后一行, this.append()this表示自定义元素实例。

    完成这一步以后,自定义元素内部的 DOM 结构就已经生成了。

    四、 <template>标签

    使用 JavaScript 写上一节的 DOM 结构很麻烦,Web Components API 提供了 <template>标签,可以在它里面使用 HTML 定义 DOM。

    <template id="userCardTemplate"><img src="https://semantic-ui.com/images/avatar2/large/kristy.png" class="image"><div class="container"><p class="name">User Name</p><p class="email">yourmail@some-email.com</p><button class="button">Follow</button></div></template>

    然后,改写一下自定义元素的类,为自定义元素加载 <template>

    
    class UserCard extends HTMLElement {
      constructor() {
        super();
    
        var templateElem = document.getElementById('userCardTemplate');
        var content = templateElem.content.cloneNode(true);
        this.appendChild(content);
      }
    }  

    上面代码中,获取 <template>节点以后,克隆了它的所有子元素,这是因为可能有多个自定义元素的实例,这个模板还要留给其他实例使用,所以不能直接移动它的子元素。

    到这一步为止,完整的代码如下。

    <body><user-card></user-card><template>...</template><script>
        class UserCard extends HTMLElement {
          constructor() {
            super();
    
            var templateElem = document.getElementById('userCardTemplate');
            var content = templateElem.content.cloneNode(true);
            this.appendChild(content);
          }
        }
        window.customElements.define('user-card', UserCard);    </script></body>

    五、添加样式

    自定义元素还没有样式,可以给它指定全局样式,比如下面这样。

    
    user-card {
      /* ... */
    }

    但是,组件的样式应该与代码封装在一起,只对自定义元素生效,不影响外部的全局样式。所以,可以把样式写在 <template>里面。

    <template id="userCardTemplate"><style>
       :host {
         display: flex;
         align-items: center;
         width: 450px;
         height: 180px;
         background-color: #d4d4d4;
         border: 1px solid #d5d5d5;
         box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
         border-radius: 3px;
         overflow: hidden;
         padding: 10px;
         box-sizing: border-box;
         font-family: 'Poppins', sans-serif;
       }
       .image {
         flex: 0 0 auto;
         width: 160px;
         height: 160px;
         vertical-align: middle;
         border-radius: 5px;
       }
       .container {
         box-sizing: border-box;
         padding: 20px;
         height: 160px;
       }
       .container > .name {
         font-size: 20px;
         font-weight: 600;
         line-height: 1;
         margin: 0;
         margin-bottom: 5px;
       }
       .container > .email {
         font-size: 12px;
         opacity: 0.75;
         line-height: 1;
         margin: 0;
         margin-bottom: 15px;
       }
       .container > .button {
         padding: 10px 25px;
         font-size: 12px;
         border-radius: 5px;
         text-transform: uppercase;
       }</style><img src="https://semantic-ui.com/images/avatar2/large/kristy.png" class="image"><div class="container"><p class="name">User Name</p><p class="email">yourmail@some-email.com</p><button class="button">Follow</button></div></template>

    上面代码中, <template>样式里面的 :host伪类,指代自定义元素本身。

    六、自定义元素的参数

    <user-card>内容现在是在 <template>里面设定的,为了方便使用,把它改成参数。

    <user-card
      image="https://semantic-ui.com/images/avatar2/large/kristy.png"
      name="User Name"
      email="yourmail@some-email.com"></user-card>

    <template>代码也相应改造。

    <template id="userCardTemplate"><style>...</style><img class="image"><div class="container"><p class="name"></p><p class="email"></p><button class="button">Follow John</button></div></template>

    最后,改一下类的代码,把参数加到自定义元素里面。

    
    class UserCard extends HTMLElement {
      constructor() {
        super();
    
        var templateElem = document.getElementById('userCardTemplate');
        var content = templateElem.content.cloneNode(true);
        content.querySelector('img').setAttribute('src', this.getAttribute('image'));
        content.querySelector('.container>.name').innerText = this.getAttribute('name');
        content.querySelector('.container>.email').innerText = this.getAttribute('email');
        this.appendChild(content);
      }
    }
    window.customElements.define('user-card', UserCard);    

    七、Shadow DOM

    我们不希望用户能够看到 <user-card>的内部代码,Web Component 允许内部代码隐藏起来,这叫做 Shadow DOM,即这部分 DOM 默认是隐藏的,开发者工具里面看不到。

    自定义元素的 this.attachShadow()方法开启 Shadow DOM,详见下面的代码。

    
    class UserCard extends HTMLElement {
      constructor() {
        super();
        var shadow = this.attachShadow( { mode: 'closed' } );
    
        var templateElem = document.getElementById('userCardTemplate');
        var content = templateElem.content.cloneNode(true);
        content.querySelector('img').setAttribute('src', this.getAttribute('image'));
        content.querySelector('.container>.name').innerText = this.getAttribute('name');
        content.querySelector('.container>.email').innerText = this.getAttribute('email');
    
        shadow.appendChild(content);
      }
    }
    window.customElements.define('user-card', UserCard);

    上面代码中, this.attachShadow()方法的参数 { mode: 'closed' },表示 Shadow DOM 是封闭的,不允许外部访问。

    至此,这个 Web Component 组件就完成了,完整代码可以访问 这里。可以看到,整个过程还是很简单的,不像第三方框架那样有复杂的 API。

    八、组件的扩展

    在前面的基础上,可以对组件进行扩展。

    (1)与用户互动

    用户卡片是一个静态组件,如果要与用户互动,也很简单,就是在类里面监听各种事件。

    
    this.$button = shadow.querySelector('button');
    this.$button.addEventListener('click', () => {
      // do something
    });

    (2)组件的封装

    上面的例子中, <template>与网页代码放在一起,其实可以用脚本把 <template>注入网页。这样的话,JavaScript 脚本跟 <template>就能封装成一个 JS 文件,成为独立的组件文件。网页只要加载这个脚本,就能使用 <user-card>组件。

    这里就不展开了,更多 Web Components 的高级用法,可以接着学习下面两篇文章。

    九、参考链接

    (完)

    文档信息

    • 版权声明:自由转载-非商用-非衍生-保持署名( 创意共享3.0许可证
    • 发表日期: 2019年8月 6日

    vue父子组件通信高级用法

    $
    0
    0

    vue项目的一大亮点就是组件化。使用组件可以极大地提高项目中代码的复用率,减少代码量。但是使用组件最大的难点就是父子组件之间的通信。

    子通信父

    父组件

    <template><div class="parent">我是父组件
        <!--父组件监听子组件触发的say方法,调用自己的parentSay方法--><!--通过:msg将父组件的数据传递给子组件--><children :msg="msg" @say="parentSay"></children></div></template><script>
    import Children from './children.vue'
    export default {
      data () {
        return {
          msg: 'hello, children'
        }
      },
      methods: {
          // 参数就是子组件传递出来的数据
          parentSay(msg){
              console.log(msg) // hello, parent
          }
      },
    
      // 引入子组件
      components:{
          children: Children
      }
    }
    </script>

    子组件

    <template><div class="hello"><div class="children" @click="say">我是子组件
          <div>父组件对我说:{{msg}}
          </div></div></div></template><script>
    
      export default {
          //父组件通过props属性传递进来的数据
          props: {
              msg: {
                  type: String,
                  default: () => {
                      return ''
                  }
              }
          },
          data () {
            return {
                childrenSay: 'hello, parent'
            }
          },
    
          methods: {
              // 子组件通过emit方法触发父组件中定义好的函数,从而将子组件中的数据传递给父组件
              say(){
                  this.$emit('say' , this.childrenSay);
              }
          }
      }</script>

    子组件使用$emit方法调用父组件的方法,达到子通信父的目的。

    父通信子

    父组件

    <!--Html--><template><!--父组件触发click方法--><div class="parent" @click="say">我是父组件
        <!--通过ref标记子组件--><children ref="child"></children></div></template><script>
    import Children from './children.vue'
    export default {
      data () {
        return {
            msg: "hello,my son"
        }
      },
      methods: {
          // 通过$refs调用子组件的parentSay方法
          say(){
             this.$refs.child.parentSay(this.msg);
          }
      },
    
      // 引入子组件
      components:{
          children: Children
      }
    }
    </script>

    子组件

    <template><div class="hello"><div class="children" >我是子组件
          <div>父组件对我说:{{msg}}
          </div></div></div></template><script>
    
      export default {
          data () {
            return {
                msg: ''
            }
          },
    
          methods: {
              // 父组件调用的JavaScript方法parentSay
              parentSay(msg){
                  this.msg = msg;
              }
          }
      }
    </script>

    父组件通过 $refs调用子组件的方法。
    以上就是父子组件通信的方式,父子组件传递数据直接用props,或者使用 $emit$refs依靠事件来传递数据。

    父子组件通信提升篇

    上文中,子通信父是在 子中触发点击事件然后调用父组件的方法,父通信子是在 父中触发点击事件调用子组件的方法。但是实际情况中可能存在 子通信父时子组件不允许有点击事件而事件在父中或者 父通信子时点击事件在子组件中。

    子通信父时击事件在父组件

    这种情况其实很常见,例如提交一个表单时表单的内容为子组件,而保存按钮在父组件上。此时点击保存按钮想要获取子组件表单的值。这种情况下已经不单单是子通信父和父通信子了,需要将两者结合在一起使用才能完成整个通信过程。

    实现的思路是在父组件中点击事件时,先通过父子通信调用子组件的方法,然后在子组件中的该方法里使用子父通信调用父组件的另一个方法并将信息传递回来。以下是代码演示:

    父组件

    <template><div class="parent" @click="getData">我是父组件
        <!--父组件监听子组件触发的transData方法,调用自己的transData方法--><!--通过ref标记子组件--><children ref="child" @transData="transData"></children></div></template><script>
    import Children from './children.vue'
    export default {
      data () {
        return {
          msg: 'hello, children'
        }
      },
      methods: {
          getData(){
              // 调用子组件的getData方法
              this.$refs.child.getData();
          },
          // 参数就是子组件传递出来的数据
          transData(msg){
              console.log(msg) // hello, parent
          }
      },
    
      // 引入子组件
      components:{
          children: Children
      }
    }
    </script>

    子组件

    <template><div class="hello"><div class="children" >我是子组件
          <div>子组件的数据:{{childrenSay}}
          </div></div></div></template><script>
    
      export default {
          data () {
            return {
                childrenSay: 'hello, parent'
            }
          },
          methods: {
              // 子组件通过emit方法触发父组件中定义好的函数,从而将子组件中的数据传递给父组件
              getData() {
                  this.$emit('transData' , this.childrenSay);
              }
          }
      }</script>

    另一种情况思路也和这个一样,基础就在与父通信子和子通信父的灵活运用。
    转评赞就是最大的鼓励

    GitHub 上周 JavaScript 趋势榜项目

    $
    0
    0

    header.png

    1. yemount/pose-animator

    项目地址: https://github.com/yemount/pose-animator

    ⭐stars:4237 | forks:354 | 2117 ⭐stars this week

    Pose Animator拍摄2D矢量图,并基于PoseNet和FaceMesh的识别结果实时对其包含的曲线进行动画处理。 它从计算机图形学中借鉴了基于骨骼的动画的思想,并将其应用于矢量字符。

    2. renrenio/renren-fast-vue

    项目地址: https://github.com/renrenio/renren-fast-vue

    ⭐stars:2023 | forks:1062 | 132 ⭐stars this week

    renren-fast-vue基于vue、element-ui构建开发,实现renren-fast后台管理前端功能,提供一套更优的前端解决方案。

    3. zeit/next.js

    项目地址: https://github.com/zeit/next.js

    ⭐stars:48399 | forks:7232 | 483 ⭐stars this week

    React SSR 框架

    4. discordjs/discord.js

    项目地址: https://github.com/discordjs/discord.js

    ⭐stars:5791 | forks:1447 | 84 ⭐stars this week

    强大的 JavaScript 库,可与 Discord API 进行交互

    5. openspug/spug

    项目地址: https://github.com/openspug/spug

    ⭐stars:2383 | forks:534 | 476 ⭐stars this week

    开源运维平台:面向中小型企业设计的轻量级无Agent的自动化运维平台,整合了主机管理、主机批量执行、主机在线终端、应用发布部署、在线任务计划、配置中心、监控、报警等一系列功能。

    6. ccxt/ccxt

    项目地址: https://github.com/ccxt/ccxt

    ⭐stars:13828 | forks:3889 | 88 ⭐stars this week

    JavaScript / Python / PHP加密货币交易API,支持超过120个 比特币 / 数字货币 交换

    7. Advanced-Frontend/Daily-Interview-Question

    项目地址: https://github.com/Advanced-Frontend/Daily-Interview-Question

    ⭐stars:18641 | forks:2284 | 207 ⭐stars this week

    我是木易杨,公众号「高级前端进阶」作者,每天搞定一道前端大厂面试题,祝大家天天进步,一年后会看到不一样的自己。

    8. quasarframework/quasar

    项目地址: https://github.com/quasarframework/quasar

    ⭐stars:14653 | forks:1667 | 77 ⭐stars this week

    Quasar Framework-在极短时间内构建高性能 VueJS 用户界面

    9. denysdovhan/wtfjs

    项目地址: https://github.com/denysdovhan/wtfjs

    ⭐stars:17262 | forks:1140 | 193 ⭐stars this week

    有趣又棘手的 JavaScript 示例列表

    10. YMFE/yapi

    项目地址: https://github.com/YMFE/yapi

    ⭐stars:15486 | forks:2619 | 310 ⭐stars this week

    YApi 是一个可本地部署的、打通前后端及QA的、可视化的接口管理平台

    11. agalwood/Motrix

    项目地址: https://github.com/agalwood/Motrix

    ⭐stars:21052 | forks:2625 | 272 ⭐stars this week

    一个全功能的下载管理器。

    12. jgraph/drawio-desktop

    项目地址: https://github.com/jgraph/drawio-desktop

    ⭐stars:7603 | forks:811 | 322 ⭐stars this week

    drawio 桌面版

    13. facebook/react

    项目地址: https://github.com/facebook/react

    ⭐stars:148832 | forks:28875 | 514 ⭐stars this week

    大名鼎鼎的 React 框架, 用于构建用户界面的声明性,高效且灵活的JavaScript库。

    14. iamadamdev/bypass-paywalls-chrome

    项目地址: https://github.com/iamadamdev/bypass-paywalls-chrome

    ⭐stars:4799 | forks:359 | 198 ⭐stars this week

    绕过付费墙 web 浏览器扩展

    15. cypress-io/cypress

    项目地址: https://github.com/cypress-io/cypress

    ⭐stars:20222 | forks:1213 | 227 ⭐stars this week

    对浏览器中运行的所有内容进行快速,轻松和可靠的测试。

    16. jgraph/drawio

    项目地址: https://github.com/jgraph/drawio

    ⭐stars:16966 | forks:3550 | 296 ⭐stars this week

    diagrams.net(以前为draw.io)是一个在线图表绘制网站,在此项目中提供了源代码。

    17. twbs/bootstrap

    项目地址: https://github.com/twbs/bootstrap

    ⭐stars:140844 | forks:68838 | 281 ⭐stars this week

    最受欢迎的HTML,CSS和JavaScript框架,用于在网络上开发响应式,移动优先项目。

    18. mui-org/material-ui

    项目地址: https://github.com/mui-org/material-ui

    ⭐stars:57433 | forks:16064 | 276 ⭐stars this week

    React UI 库,组件可以更快,更轻松地进行Web开发。 建立自己的设计系统,或从材料设计开始。

    19. gatsbyjs/gatsby

    项目地址: https://github.com/gatsbyjs/gatsby

    ⭐stars:44405 | forks:8044 | 228 ⭐stars this week

    使用React构建快速,现代化的应用程序和网站

    20. microsoft/playwright

    项目地址: https://github.com/microsoft/playwright

    ⭐stars:12773 | forks:370 | 408 ⭐stars this week

    节点库可通过单个API自动化Chromium,Firefox和WebKit

    21. MarkerHub/eblog

    项目地址: https://github.com/MarkerHub/eblog

    ⭐stars:448 | forks:149 | 100 ⭐stars this week

    eblog是一个基于Springboot2.1.2开发的博客学习项目,为了让项目融合更多的知识点,达到学习目的,编写了详细的从0到1开发文档。主要学习包括:自定义Freemarker标签,使用shiro+redis完成了会话共享,redis的zset结构完成本周热议排行榜,t-io+websocket完成即时消息通知和群聊,rabbitmq+elasticsearch完成博客内容搜索引擎等。值得学习的地方很多!

    22. goldbergyoni/nodebestpractices

    项目地址: https://github.com/goldbergyoni/nodebestpractices

    ⭐stars:43891 | forks:3974 | 293 ⭐stars this week

    Node.js最佳实践列表(2020年5月)

    23. grommet/grommet

    项目地址: https://github.com/grommet/grommet

    ⭐stars:6300 | forks:738 | 40 ⭐stars this week

    基于react的框架,可在整齐的程序包中提供可访问性,模块化,响应性和主题化

    24. egonSchiele/grokking_algorithms

    项目地址: https://github.com/egonSchiele/grokking_algorithms

    ⭐stars:4004 | forks:1558 | 49 ⭐stars this week

    《 Grokking算法》源码

    25. jonasschmedtmann/complete-javascript-course

    项目地址: https://github.com/jonasschmedtmann/complete-javascript-course

    ⭐stars:3629 | forks:5226 | 54 ⭐stars this week

    完整 JavaScript 课程的入门文件,最终项目和常见问题解答

    RTSP?不存在的 ->前端实时流探索记

    $
    0
    0

    作为一个从未接触过实时流(直播流)的人,我之前对实时视频一直没有概念,而最近参与的项目刚好有视频监控的需求,在参与技术选型之前,我对前端实时流的展示进行了一下摸底。

    概览

    视频有一个流的概念,所以称流媒体。实时视频的流很好理解,因为视频是实时的,需要有一个地方不停地输出视频出来,所以整个视频可以用流来称呼。那么视频可否直接输出到前端页面上呢?可惜,如果可以的话,就没有我这篇文章了。现在摄像头的实时视频流普遍采用的是 RTSP 协议,而前端并不能直接播放 RTSP 的视频流。

    • RTSP(Real-Time Stream Protocol),是 TCP/UDP 协议体系中的一个应用层协议,跟 HTTP 处在同一层。RTSP 在体系结构上位于 RTP 和RTCP 之上,它使用 TCP 或者 RTP 完成数据传输。RTSP 实时效果非常好,适合视频聊天、视频监控等方向。


    那么我们就需要一层中间层,来将 RTSP 流转成前端可以支持的协议,这也引申出了目前实时流技术的几种方向:

    • RTSP -> RTMP
    • RTSP -> HLS
    • RTSP -> RTMP -> HTTP-FLV

    image.png

    RTMP

    RTMP(Real Time Messaging Protocol)是属于 Adobe 的一套视频协议,这套方案需要专门的 RTMP 流媒体,并且如果想要在浏览器上播放,无法使用 HTML5 的 video 标签,只能使用 Flash 播放器。(通过使用 video.js@5.x 以下的版本可以做到用 video 标签进行播放,但仍然需要加载 Flash)。 它的实时性在几种方案中是最好的,但是由于只能使用 Flash 的方案,所以在移动端就直接 GG 了,在 PC 端也是 明日黄花
    由于下面的两种方法也需要用到 RTMP,所以这里就展示一下 RTSP 流如何转换成 RTMP ,我们使用 ffmpeg+Nginx+nginx-rtmp-module 来做这件事:

    # 在 http 同一层配置 rtmp 协议的相关字段
    rtmp {
        server {
              # 端口
            listen 1935;
                # 路径
            application test {
                # 开启实时流模式
                live on;
                record off;
            }
        }
    }
    # bash 上执行 ffmpeg 把 rtsp 转成 rtmp,并推到 1935 这个端口上
    ffmpeg -i "rtsp://xxx.xxx.xxx:xxx/1" -vcodec copy -acodec copy -f flv "rtmp://127.0.0.1:1935/live/"

    这样我们就得到了一个 RTMP 的流,我们可以直接用 VLC 或者 IINA 来播放这个流。

    HLS

    HLS(HTTP Live Streaming)是苹果公司提出的基于 HTTP 协议的的流媒体网络传输协议,它的工作原理是把整个流分成一个个小的基于 HTTP 的文件来下载,每次只下载一些。HLS 具有跨平台性,支持 iOS/Android/浏览器,通用性强。但是它的实时性差:苹果官方建议是请求到3个片之后才开始播放。所以一般很少用 HLS 做为互联网直播的传输协议。假设列表里面的包含5个 ts 文件,每个 TS 文件包含5秒的视频内容,那么整体的延迟就是25秒。苹果官方推荐的小文件时长是 10s,所以这样就会有30s(n x 10)的延迟。
    下面是 HLS 实时流的整个链路:
    image.png
    从图中可以看出来我们需要一个服务端作为编码器和流分割器,接受流并不断输出成流片段(stream),然后前端再通过一个索引文件,去访问这些流片段。那么我们同样可以使用 nginx+ffmpeg 来做这件事情。

    # 在 rtmp 的 server 下开启 hls
    # 作为上图中的 Server,负责流的处理
    application hls{
            live on;
            hls on;
             hls_path xxx/; #保存 hls 文件的文件夹
            hls_fragment 10s;
    }
    # 在 http 的 server 中添加 HLS 的配置:
    # 作为上图中的 Distribution,负责分片文件和索引文件的输出
    location /hls {
          # 提供 HLS 片段,声明类型
          types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t ts;
          }
          root /Users/mark/Desktop/hls; #访问切片文件保存的文件夹
          # Cache-Controll no-cache;
          expires -1;
    }

    然后同样使用 ffmpeg 推流到 hls 路径上:

    ffmpeg -i "rtsp://xxx.xxx.xxx:xxx/1" -vcodec copy -acodec copy -f flv rtmp://127.0.0.1:1935/hls

    这个时候可以看到文件夹里已经有许多流文件存在,且不停地更新:
    image.png
    然后我们可以使用 video.js+video.js-contrib-hls 来播放这个视频:

    <html><head><title>video</title><!-- 引入css --><link href="https://unpkg.com/video.js/dist/video-js.min.css" rel="stylesheet"></head><body><div class="videoBox"><video id="video" class="video-js vjs-default-skin" controls>    <source src="http://localhost:8080/hls/test.m3u8" type="application/x-mpegURL"> 
        </video></div></body></html><script src="https://unpkg.com/video.js/dist/video.min.js"></script><script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-hls/5.15.0/videojs-contrib-hls.min.js"></script><script>
    videojs.options.flash.swf = "./videojs/video-js.swf"
            videojs('video', {"autoplay":true}).play();</script>

    在我的测试下,HLS 的延迟在10-20秒左右,我们可以通过调整切片的大小来减少延迟,但是由于架构的限制,延迟是一个不可忽视的问题。

    HTTP-FLV

    接下来就是重头戏 HTTP-FLV 了,它集合了 HLS 的通用性和 RTMP 的实时性,可以做到在浏览器上用 HTML5 的 video 标签,以较低的延时播放实时流。HTTP-FLV 依靠 MIME 的特性,根据协议中的 Content-Type 来选择相应的程序去处理相应的内容,使得流媒体可以通过 HTTP 传输。除此之外,它可以通过 HTTP 302 跳转灵活调度/负载均衡,支持使用 HTTPS 加密传输,也能够兼容支持 Android,iOS 等移动端。 HTTP-FLV 本质上是将流转成 HTTP 协议下的 flv 文件,在 Nginx 上我们可以使用 nginx-http-flv-module 来将 RTMP 流转成 HTTP 流。

    其实 flv 格式依然是 Adobe 家的格式,原生 Video 标签无法直接播放,但是好在我们有 bilibili 家的 flv.js,它可以将 FLV 文件流转码复用成 ISO BMFF(MP4 碎片)片段,然后通过 Media Source Extensions 将 MP4 片段喂进浏览器。


    在支持浏览器的协议里,延迟排序是这样的: RTMP = HTTP-FLV = WebSocket-FLV < HLS
    而性能排序是这样的: RTMP > HTTP-FLV = WebSocket-FLV > HLS


    说了这么多,不如直接上手看看吧:

    1. 首先我们需要一个新的 nginx 插件: nginx-http-flv-module
    2. 在 nginx.conf 中进行一些新的配置:
    # rtmp server
    application myvideo {
          live on;
          gop_cache: on; #减少首屏等待时间
    }
    
    # http server
    location /live {
          flv_live on;
    }
    1. 依然用 ffmpeg 来推流,使用上面 RTMP 的命令
    2. 前端 import flv.js,然后使用它来播放
    // 前端使用 flv.js,开启实时模式,然后访问这个 nginx 地址下的路径即可
    import flvJs from 'flv.js';
    
    export function playVideo(elementId, src) {
      const videoElement = document.getElementById(elementId);
      const flvPlayer = flvJs.createPlayer({
        isLive: true,
        type: 'flv',
        url: src,
      });
      flvPlayer.attachMediaElement(videoElement);
      flvPlayer.load();
    }
    
    playVideo('#video', 'http://localhost:8080/live?port=1985&app=myvideo&stream=streamname')

    可以看到 flv.js 使用了 video/x-flv 这个 MIME 返回数据。
    image.png
    如果对延迟有更高的要求,可以尝试下面的操作:

    1. 可以配置 flv.js 的 enableStashBuffer 字段,它是 flv.js 用于控制缓存 buffer 的开关,关闭了之后可以做到最小延迟,但由于没有缓存,可能会看到网络抖动带来的视频卡顿。
    2. 可以尝试关闭 nginx 的 http 配置里的 gop_cache 。 gop_cache 又称关键帧缓存,其意义是控制视频的关键帧之间的缓存是否开启。

    这里引入了一个关键帧的概念:我们使用最广泛的 H.264 视频压缩格式,它采用了诸如帧内预测压缩/帧间预测压缩等压缩方案,最后得到了 BPI 三种帧:

    • I 帧:关键帧,采用帧内压缩技术。
    • P 帧:向前参考帧,在压缩时,只参考前面已经处理的帧,表示的是当前帧画面与前一帧(前一帧可能是 I 帧也可能是 P 帧)的差别。采用帧间压缩技术。
    • B 帧:双向参考帧,在压缩时,它即参考前面的帧,又参考它后面的帧。B 帧记录的是本帧与前后帧的差别。采用帧间压缩技术。

    带有 I 帧、B 帧和 P 帧的典型视频序列。P 帧只需要参考前面的 I 帧或 P 帧,而 B 帧则需要同时参考前面和后面的 I 帧或 P 帧。由于 P/B 帧对于 I 帧都有直接或者间接的依赖关系,所以播放器要解码一个视频帧序列,并进行播放,必须首先解码出 I 帧。假设 GOP(就是视频流中两个I帧的时间距离) 是 10 秒,也就是每隔 10 秒才有关键帧,如果用户在第 5 秒时开始播放,就无法拿到当前的关键帧了。这个时候 gop_cache 就起作用了: gop_cache 可以控制是否缓存最近的一个关键帧。开启 gop_cache 可以让客户端开始播放时,立即收到一个关键帧,显示出画面,当然,由于增加了对上一个帧的缓存,所以延时自然就变大了。如果对延时有更高的要求,而对于首屏时间/播放流畅度的要求没那么高的话,那么可以尝试关闭 gop_cache,来达到低延时的效果。

    思考

    延迟与卡顿

    实时视频的延时与卡顿是视频质量中最重要的两项指标。 然而,这两项指标从理论上来说,是一对矛盾的关系——需要更低的延时,则表明服务器端和播放端的缓冲区都必须更短,来自网络的异常抖动容易引起卡顿;业务可以接受较高的延时时,服务端和播放端都可以有较长的缓冲区,以应对来自网络的抖动,提供更流畅的体验。

    直播厂商是怎么做的?

    现在各个直播平台基本上都放弃了以上这些比较传统的方式,使用了云服务商提供的 CDN,但还是离不开前文所说的几种协议与方式。如下图是阿里云的直播服务图。可以看到其流程大概分为这几步:

    1. 采集视频流(主播端使用 RTMP 进行推流)
    2. 推流到 CDN 节点(上传流)
    3. CDN 节点转到直播中心,直播中心类似于强大的具有计算能力的中间源,可以提供额外服务诸如落存(录制/录制到云存储/点播),转码,审核,多种协议的输出等。
    4. 直播中间分发到 CDN 节点
    5. 播放(阿里云支持 RTMP、FLV 及 HLS 三种播流协议)

    image.png

    PS:如果你已经看到这儿了,觉得我写得还行的话,麻烦给个赞,谢谢!

    助力ssr,使用concent为nextjs应用加点料

    $
    0
    0

    开源不易,感谢你的支持, ❤ star concent^_^

    序言

    nextjs是一个非常流行的 React 服务端渲染应用框架,它很轻量,简单易上手,社区活跃,所以当我们使用 react写一个需要 ssr(server side render)的应用的话,基本都会首选 nextjsconcent是一个新生代的 react状态管理方案,它内置依赖收集系统,同时兼具有0入侵、可预测、渐进式、高性能的特点,并提供了 lifecylecomposition api等灵活的api且写法超级简单,让你轻松驾驭超大规模的react应用。

    Hello next

    这里我们将使用 create-next-app命令来安装一个基础的next示例应用

    npx create-next-app hello-next

    执行完毕后,可以看到一个如下的目录结构

    |____public
    |____pages
    | |____ _app.js   // next应用默认的根组件
    | |____index.js   // 默认首页
    | |____api        // api路由文件
    | | |____hello.js 

    之后我们在项目根目录执行 npm run dev将看到一个由 next驱动的 ssr默认首页

    Hello concent

    这里我们将使用 create-react-app命令来安装一个基础的concent示例应用

    npx create-react-app hello-concent --template concent-ts

    执行完毕后,可以看到一个如下的目录结构

    |____index.tsx
    |____App.tsx
    |____types            // store的类型定义处
    |____features           // 功能组件列表
    | |____counter          // counter功能
    | | |____Counter.tsx        // counter组件
    | | |____model            // counter模型(包含state,reducer,computed)
    |____models            // 其它全局通用的模型定义
    |____configs

    进入项目目录执行 npm i,然后执行 npm start即可看到一个默认的计数器页面

    你也可以点击 这里在线了解和编辑它。

    当然了在已有的项目里集成 concent里也超级简单,因为它无需顶层提供 Provider,只需要提前配置好模型即可。

    import { run } from 'concent';
    
    run({ // 定义一个counter模型
      counter: {
        state: { num: 1, bigNum: 10 },
        reducer: {
          add(payload, moduleState) {
            return { num: moduleState + 1 };
          },
          async asyncAddBig() {
            await new Promise(resolve => setTimeout(resolve, 1000));
            return { bigNum: moduleState + 10 };
          }
        },
        computed: {
          doubleNum: ({ num }) => num * 2, // 仅当num发生变化才触发此函数
        }
      }
    })

    之后就可以全局即插即用啦,类组件和函数组件都可以用同样的方式去读取数据或调用方法,敲重点啦, 如果ui处是有条件语句控制是否要消费状态或衍生数据的话,推荐延迟解构的写法,这样可以让concent在每一轮渲染完毕后收集到视图对数据的最小粒度依赖

    // ###### 函数组件
    function Demo(){
      // 如 state 和 moduleComputed 是按需读取的,推荐延迟解构的写法
      const { state: { num, numBig }, moduleComputed: { doubleNum }, mr } = useConcent('counter'); 
      // ... ui 逻辑,绑数据、绑方法
    }
    
    // ###### 类组件
    const DemoCls = register('counter')(
      class DemoCls extends React.Component{
       render(){
          const { state: { num, numBig }, moduleComputed: { doubleNum }, mr } = this.ctx; 
          // ... ui 逻辑,绑数据、绑方法
        }
      }
    )

    在next里引入concent

    next的基础示例目录里有个 _app.js文件,它是next应用的根组件

    import '../styles/globals.css'
    
    function MyApp({ Component, pageProps }) {
      return <Component {...pageProps} />
    }
    
    export default MyApp

    因使用 concent之前必需提前配置好模型,所以我们只需提前创建一个 runConcent.js文件

    import { run } from 'concent'
    import * as models from './models';
    
    run(models);

    然后在 _app.js文件引入即可,这样根组件下的所有子组件都能够正确获取到store的数据和调动store的方法了。

    import '../styles/globals.css'
    + import './runConcent'
    
    function MyApp({ Component, pageProps }) {
      return <Component {...pageProps} />
    }
    
    export default MyApp

    接着我们在next的pages目录下创建一个 counter.js文件,代表这是一个页面组件,这样浏览器端可以用 /counter路由来访问到这个组件的渲染视图了。

    import React from 'react'
    import { useConcent } from 'concent'
    import router from 'next/router'
    
    // use next/router to do browser side router jump
    function toHomePage(){
      router.push('/');
    }
    
    export default function Counter() {
      const { state, mr, moduleComputed } = useConcent('home')
    
      return (
        <div>
          this is counter page<h1>num: {state.num}</h1><h1>doubleNum: {moduleComputed.doubleNum}</h1><button onClick={mr.add}>add</button><button onClick={toHomePage}>to home page</button></div>
      );
    }

    大功告成,一个接入了 concentnext应用就这样产生了,是不是特别简单呢?^_^

    getServerSideProps 也是同样类似的做法来做哦,它们只是执行时机不同,getServerSideProps是每次请求页面都会执行,而getStaticProps是构建时执行。

    支持预渲染

    next提供两种级别的预渲染接口,即 getServerSidePropsgetStaticProps,两种的区别是执行时机不同, getServerSideProps是每次请求页面都会执行,而 getStaticProps是构建时执行,我们先处理 getServerSideProps这种情况吧,看看如何集合 concent做预渲染支持。

    首先我们不考虑 concent的存在,在 next里做预渲染支持,只需要在你的页面组件里暴露一个 getServerSideProps接口即可。

    // 此函数在每次请求改页面时被调用
    export async function getServerSideProps() {
      // 调用外部 API 获取博文列表
      const res = await fetch('https://.../posts')
      const posts = await res.json()
    
      // 通过返回 { props: posts } 对象,PostPage 组件在渲染时将接收到 `posts` 参数
      return {
        props: { posts },
      }
    }
    
    function PostPage({ posts }) { // 这里接收到了 posts 参数
      // Render posts...
    }
    
    export default PostPage

    之所以 Blog能够接到 posts,除了暴露这个 getServerSideProps这个接口之外,我们再观察一下 _app.js这个根组件文件内容,可以发现关键点所在!

    function MyApp({ Component, pageProps }) {
      return <Component {...pageProps} />
    }
    export default MyApp

    参数列表里的 pageProps即是 getServerSideProps返回结果里 props指向的对象,然后 next将其透传到目标页面组件上,所以我们才能够在 PostPage参数列表里解构出 posts

    所以我们的切入点就可以从这里入手了,我们把getStaticProps的返回结果做一下格式约束,形如 {module:string, state: object}这样的结构,然后在 _app.js文件里记录到store即可

    // 此函数在每次请求时被调用
    export async function getServerSideProps() {
      // 调用外部 API 获取博文列表
      await delay();
      const posts = [
        { id: 1, name: 'post1 -----' },
        { id: 2, name: 'post2 --- welcome to use concent' },
      ];
      // 这个返回对象会透传给根组件的pageProps,在此返回状态所属的模块和状态实体对象
      // 在那里将状态记录到store
      return {
        props: {
          module: 'test',
          state: { posts },
        }
      };
    }

    此时的根组件文件改变如下

    import '../styles/globals.css';
    + import './runConcent';
    + import { setState } from 'concent';
    
    function MyApp({ Component, pageProps }) {
      // 这里记录 getServerSideProps 的返回状态到store的对应模块
    +  if (pageProps.module) {
    +    setState(pageProps.module, pageProps.state);
    +  }
      return <Component {...pageProps} />
    }
    export default MyApp;

    然后我们实现的页面组件 post-page代码如下

    const PostList = React.memo(function () {
      const { state } = useConcent('test');
      return (<div>
          {state.posts.map(item => <h3 key={item.id}>{item.name}</h3>)}</div>
      );
    });
    
    const PostLength = React.memo(function () {
      const { state } = useConcent('test');
      return <h1>{state.posts.length}</h1>;
    });
    
    export default function PostPage() {
      return (
        <div><h1>this is post page</h1><PostList /><PostLength /><button onClick={toHomePage}>to home page</button></div>
      );
    }

    接着我们打开浏览器访问 /post-page页面吧,点击查看源码将会看到这是一个服务器端预渲染的页面

    同理,我们也可将 getServerSideProps替换为 getStaticProps,上面的整个流程将依然正常工作,欢迎各位看官clone示例代码来亲自体验一下。

    git clone https://github.com/concentjs/ssr-demo-1

    附录

    doc

    CloudBase CMS

    欢迎小哥哥们来撩 CloudBase CMS,打造一站式云端内容管理系统,它是云开发推出的,基于 Node.js 的 Headless 内容管理平台,提供了丰富的内容管理功能,安装简单,易于二次开发,并与云开发的生态体系紧密结合,助力开发者提升开发效率。

    concent已为其管理后台提供强力支持,新版的管理界面更加美观和体贴了。

    FFCreator

    也欢迎小哥哥们来撩 FFCreator,它是一个基于node.js的轻量、灵活的短视频加工库。您只需要添加几张图片或视频片段再加一段背景音乐,就可以快速生成一个很酷的视频短片。

    FFCreator是一种轻量又简单的解决方案,只需要很少的依赖和较低的机器配置就可以快速开始工作。并且它模拟实现了animate.css90%的动画效果,您可以轻松地把 web 页面端的动画效果转为视频,真的很给力。

    中小型前端团队代码规范工程化最佳实践 - ESLint

    $
    0
    0

    前言

    There are a thousand Hamlets in a thousand people's eyes.

    一千个程序员,就有一千种代码风格。在前端开发中,有几个至今还在争论的代码风格差异:

    • 单引号还是双引号?
    • 代码行结束是否需要分号?
    • 两个空格还是四个空格?
    • ...

    这几个代码风格差异在协同开发中经常会被互相吐槽,甚至不能忍受。

    除此之外,由于 JavaScript 的灵活性,往往一段代码能有多种写法,这时候也会导致协同时差异。并且,有一些写法可能会导致不易发现的 bug,或者这些写法的性能不好,开发时也应该避免。

    为了解决这类静态代码问题,每个团队都需要一个统一的 JavaScript 代码规范,团队成员都遵守这份代码规范来编写代码。当然,靠人来保障代码规范是不可靠的,需要有对应的工具来保障,ESLint 就是这个工具。

    有的读者看到这里,可能会说:Prettier 也可以保证代码风格一致。是的,Prettier 确实可以按照设置的规则对代码进行统一格式化,后面的文章也会有对应的介绍。但是需要明确的一点是,Prettier 只会在格式上对代码进行格式化,一些隐藏的代码质量问题 Prettier 是无法发现的,而 ESLint 可以。

    关于 ESLint

    关于 ESLint,它的 Slogan 是 Find and fix problems in your JavaScript code。如上文所说,它可以发现并修复你 JavaScript 代码中的问题。来看一下官网上描述 ESLint 具备的三个特性:

    • Find Problems。ESLint 通过静态代码分析可以快速发现代码中的问题。ESLint 可以运行在大多数文本编辑器中,并且也可以在工作流中接入 ESLint
    • Fix Automatically。ESLint 发现的很多问题都可以自动修复
    • Customize。可以定制 ESLint 检查规则

    基于以上描述,我们在前端工程化中可以这样使用 ESLint:

    1. 基于业界现有的 ESLint 规范和团队代码习惯定制一套统一的 ESLint 代码规则
    2. 将统一代码规则封装成 ESLint 规则包接入
    3. 将 ESLint 接入脚手架、编辑器以及研发工作流中

    快速上手

    先简单介绍一下如何使用 ESLint,如果已经有所了解的同学,可以直接跳过这一节。

    新建一个包含 package.json 的目录(可以在空目录下执行 npm init -y),新建一个 index.js

    // index.js
    const name = 'axuebin'

    安装 eslint :

    npm install eslint --save-dev

    然后执行 ./node_modules/.bin/eslint --init 或者 npx eslint --init 生成一个 ESLint 配置文件 .eslintc.js

    module.exports = {
      env: {
        es2021: true,
      },
      extends: 'eslint:recommended',
      parserOptions: {
        ecmaVersion: 12,
      },
      rules: {},
    };

    生成好配置文件之后,就可以执行 ./node_modules/.bin/eslint index.js或者 npx eslint index.js命令对文件进行检查。结果如下:
    image.png
    index.js 中的代码命中了 no-unused-vars 这个规则,默认情况下,这个规则是会报 error 的,也就是 ESLint 不允许代码中出现未被使用的变量。这是一个好习惯,有利于代码的维护。

    简单配置

    我们来尝试配置 ESLint 的检查规则。以分号和引号举例,现在你作为团队代码规范的指定人,希望团队成员开发的代码,都是 单引号带分号的。

    打开 .eslintrc.js 配置文件,在 rules 中添加相关配置项:

    module.exports = {
      env: {
        es2021: true,
      },
      extends: 'eslint:recommended',
      parserOptions: {
        ecmaVersion: 12,
      },
      rules: {
        semi: ['error', 'always'],
        quotes: ['error', 'single'],
      },
    };

    然后我们将 index.js 中的代码改成:

    // index.js
    const name = "axuebin"

    执行 eslint 命令之后:
    image.png
    可以看到检查结果如下:

    • [no-unused-vars] 'name' is assigned a value but never used。定义了 name 变量却未使用。
    • [quotes] Strings must use singlequote。字符串必须使用单引号。
    • [semi] Missing semicolon。缺失分号。

    老老实实地按照规范修改代码,使用单引号并将加上分号。当然,如果你们希望是双引号和不带分号,修改相应的配置即可。

    具体各个规则如何配置可以查看: https://eslint.org/docs/rules

    自动修复

    执行 eslint xxx --fix 可以自动修复一些代码中的问题,将无法自动修复的问题暴露出来。比如上文中提到的引号和分号的问题,就可以通过 --fix 自动修复,而 no-unused-vars 变量未使用的问题,ESLint 就无法自动修复。
    image.png

    使用配置包

    init 生成的配置文件中,我们看到包含这一行代码:

    module.exports = {
      extends: "eslint:recommended"
    }

    这一行代码的意思是,使用 ESLint 的推荐配置。 extends: 'xxx' 就是 继承,当前的配置继承于 xxx 的配置,在此基础上进行扩展。

    因此,我们也可以使用任意封装好的配置,可以在 NPM上或者 GItHub上搜索 eslint-config关键词获取,本文我们将这类封装好的配置称作 “配置集”。比较常见的配置包有以下几个:

    • eslint-config-airbnb: Airbnb 公司提供的配置集
    • eslint-config-prettier: 使用这个配置集,会关闭一些可能与 Prettier 冲突的规则
    • eslint-config-react: create react app 使用的配置集
    • eslint-config-vue: vuejs 使用的配置集
    • ...

    最佳实践

    简单了解完 ESLint 之后,对于 ESLint 的更多使用细节以及原理,在本篇文章就不展开了,感兴趣的朋友可以在官网详细了解。本文重点还是在于 如何在团队工程化体系中落地 ESLint,这里提几个最佳实践。

    抽象配置集

    对于独立开发者以及业务场景比较简单的小型团队而言,使用现成、完备的第三方配置集是非常高效的,可以较低成本低接入 ESLint 代码检查。

    但是,对于中大型团队而言,在实际代码规范落地的过程中我们会发现,不可能存在一个能够完全符合团队风格的三方配置包,我们还是会在 extends三方配置集的基础上,再手动在 rules配置里加一些自定义的规则。时间长了,有可能 A 应用和 B 应用里的 rules就不一样了,就很难达到统一的目的。

    这时候,就需要一个中心化的方式来管理配置包: 根据团队代码风格整理(或者基于现有的三方配置集)发布一个配置集,团队统一使用这个包,就可以做到中心化管理和更新

    除此之外,从技术层面考虑,目前一个前端团队的面对的场景可能比较复杂。比如:

    • 技术选型不一致:框架上 PC 使用 React,H5 使用 Vue;是否使用 TypeScript
    • 跨端场景多:Web 端和小程序端,还有 Node
    • ...

    以上问题在真实开发中都是存在的,所以在代码规范的工程化方案落地时,一个单一功能的配置集是不够用的,这时候还需要考虑这个配置集如何抽象。

    为了解决以上问题,这里提供一种解决方案的思路:
    image.png
    具体拆解来看,就是有一个类似 eslint-config-standard 的基础规则集(包括代码风格、变量相关、ES6 语法等),在此基础之上集成社区的一些插件(Vue/React)等,封装成统一的一个 NPM Package 发布,消费时根据当前应用类型通过不同路径来 extends 对应的配置集。

    这里有一个 Demo,感兴趣的朋友可以看一下: eslint-config-axuebin

    开发插件

    ESLint 提供了丰富的配置供开发者选择,但是在复杂的业务场景和特定的技术栈下,这些通用规则是不够用的。ESLint 通过插件的形式赋予了扩展性,开发者可以自定义任意的检查规则,比如 eslint-plugin-vue / eslint-plugin-react 就是 Vue / React 框架中使用的扩展插件,官网也提供了 相关文档引导开发者开发一个插件。

    一般来说,我们也不需要开发插件,但我们至少需要了解有这么个东西。在做一些团队代码质量检查的时候,我们可能会有一些特殊的业务逻辑,这时候 ESLint 插件是可以帮助我们做一些事情。

    这里就不展开了,主要就是一些 AST 的用法,照着官方文档就可以上手,或者可以参考现有的一些插件写法。

    脚手架 / CLI 工具

    当有了团队的统一 ESLint 配置集和插件之后,我们会将它们集成到脚手架中,方便新项目集成和开箱即用。但是对于一些老项目,如果需要手动改造还是会有一些麻烦的,这时候就可以借助于 CLI 来完成一键升级。

    本文结合上文的 Demo eslint-config-axuebin,设计一个简单的 CLI Demo。由于当前配置也比较简单,所以 CLI 只需要做几件简单的事情即可:

    • 询问用户当前项目的类型(是 JavaScript 还是 TypeScript、是 React 还是 Vue)
    • 根据项目类型写 .eslintrc.js 文件
    • 根据项目类型安装所需依赖(比如 vue 需要 eslint-plugin-vue)
    • package.json 的 scripts 中写入 "lint": "eslint src test --fix"  

    核心代码如下:

    const path = require('path');
    const fs = require('fs');
    const chalk = require('chalk');
    const spawn = require('cross-spawn');
    
    const { askForLanguage, askForFrame } = require('./ask');
    const { eslintrcConfig, needDeps } = require('./config');
    
    module.exports = async () => {
      const language = await askForLanguage();
      const frame = await askForFrame();
    
      let type = language;
      if (frame) {
        type += `/${frame}`;
      }
    
      fs.writeFileSync(
        path.join(process.cwd(), '.eslintrc.js'),
        `// Documentation\n// https://github.com/axuebin/eslint-config-axuebin\nmodule.exports = ${JSON.stringify(
          eslintrcConfig(type),
          null,
          2
        )}`
      );
    
      const deps = needDeps.javascript;
      if (language === 'typescript') {
        deps.concat(needDeps.typescript);
      }
      if (frame) {
        deps.concat(needDeps[frame]);
      }
    
      spawn.sync('npm', ['install', ...deps, '--save'], { stdio: 'inherit' });
    };

    可运行的 CLI Demo 代码见: axb-lint,在项目目录下执行: axblint eslint即可,如图:
    image.png

    自动化

    配置了 ESLint 之后,我们需要让开发者感知到 ESLint 的约束。开发者可以自己运行 eslint 命令来跑代码检查,这不够高效,所以我们需要一些自动化手段来做这个事情。当然 在开发时,编辑器也有提供相应的功能可以根据当前工作区下的 ESLint 配置文件来检查当前正在编辑的文件,这个不是我们关心的重点。

    一般我们会在有以下几种方式做 ESLint 检查:

    • 开发时:依赖编辑器的能力
    • 手动运行:在终端中手动执行 eslint 命令
    • pre-commit:在提交 git 前自动执行 eslint 命令
    • ci:依赖 git 的持续集成,可以将检查结果输出文件上传到服务器

    这里提一下 pre-commit 的方案,在每一次本地开发完成提交代码前就做 ESLint 检查,保证云端的代码是统一规范的。

    这种方式非常简单,只需要在项目中依赖 huskylint-staged即可完成。安装好依赖之后,在 package.json 文件加入以下配置即可:

    {"lint-staged": {"*.{js,jsx,ts,tsx}": "eslint --cache --fix"
      },"husky": {"hooks": {"pre-commit": "lint-staged"
        }
      }
    }

    效果如图所示:
    image.png
    如果代码跑 ESLint 检查抛了 Error 错误,则会中断 commit 流程:
    image.png
    这样就可以确保提交到 GitHub 仓库上的代码是统一规范的。(当然,如果认为将这些配置文件都删了,那也是没办法的)

    总结

    本文介绍了 ESLint 在中小型前端团队的一些最佳实践的想法,大家可以在此基础上扩展,制订一套完善的 ESLint 工作流,落地到自己团队中。

    本文是前端代码规范系列文章的其中一篇,后续还有关于 StyleLint/CommitLint/Prettier 等的文章,并且还有一篇 完整的关于前端代码规范工程化实践的文章,敬请期待(也有可能就鸽了)。


    更多原创文章欢迎关注公众号「 玩相机的程序员」,或者加我微信 xb9207 交流

    写给中高级前端关于性能优化的9大策略和6大指标 | 网易四年实践

    $
    0
    0

    前言

    笔者近半年一直在参与项目重构,在重构过程中大量应用 性能优化设计模式两方面的知识。 性能优化设计模式两方面的知识不管在工作还是面试时都是高频应用场景,趁着这次参与大规模项目重构的机会,笔者认真梳理出一些常规且必用的 性能优化建议,同时结合日常开发经验整理出笔者在网易四年来实践到的认为有用的所有 性能优化建议,与大家一起分享分享!(由于篇幅有限,那 设计模式在后面再专门出一篇文章呗)

    可能有些 性能优化建议已被大家熟知,不过也不影响这次分享,当然笔者也将一些平时可能不会注意的细节罗列出来。

    平时大家认为 性能优化是一种无序的应用场景,但在笔者看来它是一种有序的应用场景且很多 性能优化都是互相铺垫甚至一带一路。从过程趋势来看, 性能优化可分为 网络层面渲染层面;从结果趋势来看, 性能优化可分为 时间层面体积层面。简单来说就是 要在访问网站时使其快准狠地立马呈现在用户眼前

    性能优化.png

    所有的 性能优化都围绕着 两大层面两小层面实现,核心层面是 网络层面渲染层面,辅助层面是 时间层面体积层面,而辅助层面则充满在核心层面里。于是笔者通过本文整理出关于前端 性能优化九大策略六大指标。当然这些 策略指标都是笔者自己定义,方便通过某种方式为性能优化做一些规范。

    因此在工作或面试时结合这些特征就能完美地诠释 性能优化所延伸出来的知识了。 前方高能,不看也得收藏,走起!!!

    所有代码示例为了凸显主题,只展示核心配置代码,其他配置并未补上,请自行脑补

    九大策略

    网络层面

    网络层面的性能优化,无疑是如何让资源 体积更小加载更快,因此笔者从以下四方面做出建议。

    • 构建策略:基于构建工具( Webpack/Rollup/Parcel/Esbuild/Vite/Gulp)
    • 图像策略:基于图像类型( JPG/PNG/SVG/WebP/Base64)
    • 分发策略:基于内容分发网络( CDN)
    • 缓存策略:基于浏览器缓存( 强缓存/协商缓存)

    上述四方面都是一步接着一步完成,充满在整个项目流程里。 构建策略图像策略处于开发阶段, 分发策略缓存策略处于生产阶段,因此在每个阶段都可检查是否按顺序接入上述策略。通过这种方式就能最大限度增加 性能优化应用场景。

    构建策略

    该策略主要围绕 webpack做相关处理,同时也是接入最普遍的 性能优化策略。其他构建工具的处理也是大同小异,可能只是配置上不一致。说到 webpack性能优化,无疑是从 时间层面体积层面入手。

    笔者发现目前webpack v5整体兼容性还不是特别好,某些功能配合第三方工具可能出现问题,故暂未升级到v5,继续使用v4作为生产工具,故以下配置均基于v4,但总体与v5的配置出入不大

    笔者对两层面分别做出6个 性能优化建议总共12个 性能优化建议,为了方便记忆均使用四字词语概括,方便大家消化。⏱表示 减少打包时间,表示 减少打包体积

    • 减少打包时间缩减范围缓存副本定向搜索提前构建并行构建可视结构
    • 减少打包体积分割代码摇树优化动态垫片按需加载作用提升压缩资源
    ⏱缩减范围

    配置include/exclude缩小Loader对文件的搜索范围,好处是 避免不必要的转译node_modules目录的体积这么大,那得增加多少时间成本去检索所有文件啊?

    include/exclude通常在各大 Loader里配置, src目录通常作为源码目录,可做如下处理。当然 include/exclude可根据实际情况修改。

    export default {
        // ...
        module: {
            rules: [{
                exclude: /node_modules/,
                include: /src/,
                test: /\.js$/,
                use: "babel-loader"
            }]
        }
    };
    ⏱缓存副本

    配置cache缓存Loader对文件的编译副本,好处是 再次编译时只编译修改过的文件。未修改过的文件干嘛要随着修改过的文件重新编译呢?

    大部分 Loader/Plugin都会提供一个可使用编译缓存的选项,通常包含 cache字眼。以 babel-loadereslint-webpack-plugin为例。

    import EslintPlugin from "eslint-webpack-plugin";
    
    export default {
        // ...
        module: {
            rules: [{
                // ...
                test: /\.js$/,
                use: [{
                    loader: "babel-loader",
                    options: { cacheDirectory: true }
                }]
            }]
        },
        plugins: [
            new EslintPlugin({ cache: true })
        ]
    };
    ⏱定向搜索

    配置resolve提高文件的搜索速度,好处是 定向指定必须文件路径。若某些第三方库以常规形式引入可能报错或希望程序自动索引特定类型文件都可通过该方式解决。

    alias映射模块路径, extensions表明文件后缀, noParse过滤无依赖文件。通常配置 aliasextensions就足够。

    export default {
        // ...
        resolve: {
            alias: {"#": AbsPath(""), // 根目录快捷方式
                "@": AbsPath("src"), // src目录快捷方式
                swiper: "swiper/js/swiper.min.js"
            }, // 模块导入快捷方式
            extensions: [".js", ".ts", ".jsx", ".tsx", ".json", ".vue"] // import路径时文件可省略后缀名
        }
    };
    ⏱提前构建

    配置DllPlugin将第三方依赖提前打包,好处是 将DLL与业务代码完全分离且每次只构建业务代码。这是一个古老配置,在 webpack v2时已存在,不过现在 webpack v4+已不推荐使用该配置,因为其版本迭代带来的性能提升足以忽略 DllPlugin所带来的效益。

    DLL意为 动态链接库,指一个包含可由多个程序同时使用的代码库。在前端领域里可认为是另类缓存的存在,它把公共代码打包为DLL文件并存到硬盘里,再次打包时动态链接 DLL文件就无需再次打包那些公共代码,从而提升构建速度,减少打包时间。

    配置 DLL总体来说相比其他配置复杂,配置流程可大致分为三步。

    首先告知构建脚本哪些依赖做成 DLL并生成 DLL文件DLL映射表文件

    import { DefinePlugin, DllPlugin } from "webpack";
    
    export default {
        // ...
        entry: {
            vendor: ["react", "react-dom", "react-router-dom"]
        },
        mode: "production",
        optimization: {
            splitChunks: {
                cacheGroups: {
                    vendor: {
                        chunks: "all",
                        name: "vendor",
                        test: /node_modules/
                    }
                }
            }
        },
        output: {
            filename: "[name].dll.js", // 输出路径和文件名称
            library: "[name]", // 全局变量名称:其他模块会从此变量上获取里面模块
            path: AbsPath("dist/static") // 输出目录路径
        },
        plugins: [
            new DefinePlugin({
                "process.env.NODE_ENV": JSON.stringify("development") // DLL模式下覆盖生产环境成开发环境(启动第三方依赖调试模式)
            }),
            new DllPlugin({
                name: "[name]", // 全局变量名称:减小搜索范围,与output.library结合使用
                path: AbsPath("dist/static/[name]-manifest.json") // 输出目录路径
            })
        ]
    };

    然后在 package.json里配置执行脚本且每次构建前首先执行该脚本打包出 DLL文件

    {"scripts": {"dll": "webpack --config webpack.dll.js"
        }
    }

    最后链接 DLL文件并告知 webpack可命中的 DLL文件让其自行读取。使用 html-webpack-tags-plugin在打包时自动插入 DLL文件

    import { DllReferencePlugin } from "webpack";
    import HtmlTagsPlugin from "html-webpack-tags-plugin";
    
    export default {
        // ...
        plugins: [
            // ...
            new DllReferencePlugin({
                manifest: AbsPath("dist/static/vendor-manifest.json") // manifest文件路径
            }),
            new HtmlTagsPlugin({
                append: false, // 在生成资源后插入
                publicPath: "/", // 使用公共路径
                tags: ["static/vendor.dll.js"] // 资源路径
            })
        ]
    };

    为了那几秒钟的时间成本,笔者建议配置上较好。当然也可使用 autodll-webpack-plugin代替手动配置。

    ⏱并行构建

    配置Thread将Loader单进程转换为多进程,好处是 释放CPU多核并发的优势。在使用 webpack构建项目时会有大量文件需解析和处理,构建过程是计算密集型的操作,随着文件增多会使构建过程变得越慢。

    运行在 Node里的 webpack是单线程模型,简单来说就是 webpack待处理的任务需一件件处理,不能同一时刻处理多件任务。

    文件读写计算操作无法避免,能不能让 webpack同一时刻处理多个任务,发挥多核 CPU电脑的威力以提升构建速度呢? thread-loader来帮你,根据 CPU个数开启线程。

    在此需注意一个问题,若项目文件不算多就不要使用该 性能优化建议,毕竟开启多个线程也会存在性能开销。

    import Os from "os";
    
    export default {
        // ...
        module: {
            rules: [{
                // ...
                test: /\.js$/,
                use: [{
                    loader: "thread-loader",
                    options: { workers: Os.cpus().length }
                }, {
                    loader: "babel-loader",
                    options: { cacheDirectory: true }
                }]
            }]
        }
    };
    ⏱可视结构

    配置BundleAnalyzer分析打包文件结构,好处是 找出导致体积过大的原因。从而通过分析原因得出优化方案减少构建时间。 BundleAnalyzerwebpack官方插件,可直观分析 打包文件的模块组成部分、模块体积占比、模块包含关系、模块依赖关系、文件是否重复、压缩体积对比等可视化数据。

    可使用 webpack-bundle-analyzer配置,有了它,我们就能快速找到相关问题。

    import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
    
    export default {
        // ...
        plugins: [
            // ...
            BundleAnalyzerPlugin()
        ]
    };
    分割代码

    分割各个模块代码,提取相同部分代码,好处是 减少重复代码的出现频率webpack v4使用 splitChunks替代 CommonsChunksPlugin实现代码分割。

    splitChunks配置较多,详情可参考 官网,在此笔者贴上常用配置。

    export default {
        // ...
        optimization: {
            runtimeChunk: { name: "manifest" }, // 抽离WebpackRuntime函数
            splitChunks: {
                cacheGroups: {
                    common: {
                        minChunks: 2,
                        name: "common",
                        priority: 5,
                        reuseExistingChunk: true, // 重用已存在代码块
                        test: AbsPath("src")
                    },
                    vendor: {
                        chunks: "initial", // 代码分割类型
                        name: "vendor", // 代码块名称
                        priority: 10, // 优先级
                        test: /node_modules/ // 校验文件正则表达式
                    }
                }, // 缓存组
                chunks: "all" // 代码分割类型:all全部模块,async异步模块,initial入口模块
            } // 代码块分割
        }
    };
    摇树优化

    删除项目中未被引用代码,好处是 移除重复代码和未使用代码摇树优化首次出现于 rollup,是 rollup的核心概念,后来在 webpack v2里借鉴过来使用。

    摇树优化只对 ESM规范生效,对其他模块规范失效。 摇树优化针对静态结构分析,只有 import/export才能提供静态的 导入/导出功能。因此在编写业务代码时必须使用 ESM规范才能让 摇树优化移除重复代码和未使用代码。

    webpack里只需将打包环境设置成 生产环境就能让 摇树优化生效,同时业务代码使用 ESM规范编写,使用 import导入模块,使用 export导出模块。

    export default {
        // ...
        mode: "production"
    };
    动态垫片

    通过垫片服务根据UA返回当前浏览器代码垫片,好处是 无需将繁重的代码垫片打包进去。每次构建都配置 @babel/preset-envcore-js根据某些需求将 Polyfill打包进来,这无疑又为代码体积增加了贡献。

    @babel/preset-env提供的 useBuiltIns可按需导入 Polyfill

    • false:无视 target.browsers将所有 Polyfill加载进来
    • entry:根据 target.browsers将部分 Polyfill加载进来(仅引入有浏览器不支持的 Polyfill,需在入口文件 import "core-js/stable")
    • usage:根据 target.browsers和检测代码里ES6的使用情况将部分 Polyfill加载进来(无需在入口文件 import "core-js/stable")

    在此推荐大家使用 动态垫片动态垫片可根据浏览器 UserAgent返回当前浏览器 Polyfill,其思路是根据浏览器的 UserAgentbrowserlist查找出当前浏览器哪些特性缺乏支持从而返回这些特性的 Polyfill。对这方面感兴趣的同学可参考 polyfill-librarypolyfill-service的源码。

    在此提供两个 动态垫片服务,可在不同浏览器里点击以下链接看看输出不同的 Polyfill。相信 IExplore还是最多 Polyfill的,它自豪地说: 我就是我,不一样的烟火

    使用 html-webpack-tags-plugin在打包时自动插入 动态垫片

    import HtmlTagsPlugin from "html-webpack-tags-plugin";
    
    export default {
        plugins: [
            new HtmlTagsPlugin({
                append: false, // 在生成资源后插入
                publicPath: false, // 使用公共路径
                tags: ["https://polyfill.alicdn.com/polyfill.min.js"] // 资源路径
            })
        ]
    };
    按需加载

    将路由页面/触发性功能单独打包为一个文件,使用时才加载,好处是 减轻首屏渲染的负担。因为项目功能越多其打包体积越大,导致首屏渲染速度越慢。

    首屏渲染时只需对应 JS代码而无需其他 JS代码,所以可使用 按需加载webpack v4提供模块按需切割加载功能,配合 import()可做到首屏渲染减包的效果,从而加快首屏渲染速度。只有当触发某些功能时才会加载当前功能的 JS代码

    webpack v4提供魔术注解命名 切割模块,若无注解则切割出来的模块无法分辨出属于哪个业务模块,所以一般都是一个业务模块共用一个 切割模块的注解名称。

    const Login = () => import( /* webpackChunkName: "login" */ "../../views/login");
    const Logon = () => import( /* webpackChunkName: "logon" */ "../../views/logon");

    运行起来控制台可能会报错,在 package.jsonbabel相关配置里接入 @babel/plugin-syntax-dynamic-import即可。

    {
        // ..."babel": {
            // ..."plugins": [
                // ..."@babel/plugin-syntax-dynamic-import"
            ]
        }
    }
    作用提升

    分析模块间依赖关系,把打包好的模块合并到一个函数中,好处是 减少函数声明和内存花销作用提升首次出现于 rollup,是 rollup的核心概念,后来在 webpack v3里借鉴过来使用。

    在未开启 作用提升前,构建后的代码会存在大量函数闭包。由于模块依赖,通过 webpack打包后会转换成 IIFE,大量函数闭包包裹代码会导致打包体积增大( 模块越多越明显)。在运行代码时创建的函数作用域变多,从而导致更大的内存开销。

    在开启 作用提升后,构建后的代码会按照引入顺序放到一个函数作用域里,通过适当重命名某些变量以防止变量名冲突,从而减少函数声明和内存花销。

    webpack里只需将打包环境设置成 生产环境就能让 作用提升生效,或显式设置 concatenateModules

    export default {
        // ...
        mode: "production"
    };
    // 显式设置
    export default {
        // ...
        optimization: {
            // ...
            concatenateModules: true
        }
    };
    压缩资源

    压缩HTML/CSS/JS代码,压缩字体/图像/音频/视频,好处是 更有效减少打包体积。极致地优化代码都有可能不及优化一个资源文件的体积更有效。

    针对 HTML代码,使用 html-webpack-plugin开启压缩功能。

    import HtmlPlugin from "html-webpack-plugin";
    
    export default {
        // ...
        plugins: [
            // ...
            HtmlPlugin({
                // ...
                minify: {
                    collapseWhitespace: true,
                    removeComments: true
                } // 压缩HTML
            })
        ]
    };

    针对 CSS/JS代码,分别使用以下插件开启压缩功能。其中 OptimizeCss基于 cssnano封装, UglifyjsTerser都是 webpack官方插件,同时需注意压缩 JS代码需区分 ES5ES6

    import OptimizeCssAssetsPlugin from "optimize-css-assets-webpack-plugin";
    import TerserPlugin from "terser-webpack-plugin";
    import UglifyjsPlugin from "uglifyjs-webpack-plugin";
    
    const compressOpts = type => ({
        cache: true, // 缓存文件
        parallel: true, // 并行处理
        [`${type}Options`]: {
            beautify: false,
            compress: { drop_console: true }
        } // 压缩配置
    });
    const compressCss = new OptimizeCssAssetsPlugin({
        cssProcessorOptions: {
            autoprefixer: { remove: false }, // 设置autoprefixer保留过时样式
            safe: true // 避免cssnano重新计算z-index
        }
    });
    const compressJs = USE_ES6
        ? new TerserPlugin(compressOpts("terser"))
        : new UglifyjsPlugin(compressOpts("uglify"));
    
    export default {
        // ...
        optimization: {
            // ...
            minimizer: [compressCss, compressJs] // 代码压缩
        }
    };

    针对 字体/音频/视频文件,还真没相关 Plugin供我们使用,就只能拜托大家在发布项目到生产服前使用对应的压缩工具处理了。针对 图像文件,大部分 Loader/Plugin封装时均使用了某些图像处理工具,而这些工具的某些功能又托管在国外服务器里,所以导致经常安装失败。具体解决方式可回看笔者曾经发布的 《聊聊NPM镜像那些险象环生的坑》一文寻求答案。

    鉴于此,笔者花了一点小技巧开发了一个 Plugin用于配合 webpack压缩图像,详情请参考 tinyimg-webpack-plugin

    import TinyimgPlugin from "tinyimg-webpack-plugin";
    
    export default {
        // ...
        plugins: [
            // ...
            TinyimgPlugin()
        ]
    };

    上述 构建策略都集成到笔者开源的 bruce-cli里,它是一个 React/Vue应用自动化构建脚手架,其零配置开箱即用的优点非常适合入门级、初中级、快速开发项目的前端同学使用,还可通过创建 brucerc.js文件覆盖其默认配置,只需专注业务代码的编写无需关注构建代码的编写,让项目结构更简洁。详情请戳 这里,使用时记得查看文档,支持一个 Star哈!

    图像策略

    该策略主要围绕 图像类型做相关处理,同时也是接入成本较低的 性能优化策略。只需做到以下两点即可。

    • 图像选型:了解所有图像类型的特点及其何种应用场景最合适
    • 图像压缩:在部署到生产环境前使用工具或脚本对其压缩处理

    图像选型一定要知道每种图像类型的 体积/质量/兼容/请求/压缩/透明/场景等参数相对值,这样才能迅速做出判断在何种场景使用何种类型的图像。

    类型体积质量兼容请求压缩透明场景
    JPG有损不支持背景图、轮播图、色彩丰富图
    PNG无损支持图标、透明图
    SVG无损支持图标、矢量图
    WebP兼备支持看兼容情况
    Base64看情况无损支持图标

    图像压缩可在上述 构建策略-压缩资源里完成,也可自行使用工具完成。由于现在大部分 webpack图像压缩工具不是安装失败就是各种环境问题( 你懂的),所以笔者还是推荐在发布项目到生产服前使用图像压缩工具处理,这样运行稳定也不会增加打包时间。

    好用的图像压缩工具无非就是以下几个,若有更好用的工具麻烦在评论里补充喔!

    工具开源收费API免费体验
    QuickPicture✖️✔️✖️可压缩类型较多,压缩质感较好,有体积限制,有数量限制
    ShrinkMe✖️✖️✖️可压缩类型较多,压缩质感一般,无数量限制,有体积限制
    Squoosh✔️✖️✔️可压缩类型较少,压缩质感一般,无数量限制,有体积限制
    TinyJpg✖️✔️✔️可压缩类型较少,压缩质感很好,有数量限制,有体积限制
    TinyPng✖️✔️✔️可压缩类型较少,压缩质感很好,有数量限制,有体积限制
    Zhitu✖️✖️✖️可压缩类型一般,压缩质感一般,有数量限制,有体积限制

    若不想在网站里来回拖动图像文件,可使用笔者开源的图像批处理工具 img-master代替,不仅有压缩功能,还有分组功能、标记功能和变换功能。目前笔者负责的全部项目都使用该工具处理,一直用一直爽!

    图像策略也许处理一张图像就能完爆所有 构建策略,因此是一种很廉价但极有效的 性能优化策略

    分发策略

    该策略主要围绕 内容分发网络做相关处理,同时也是接入成本较高的 性能优化策略,需足够资金支持。

    虽然接入成本较高,但大部分企业都会购买一些 CDN服务器,所以在部署的事情上就不用过分担忧,尽管使用就好。该策略尽量遵循以下两点就能发挥 CDN最大作用。

    • 所有静态资源走CDN:开发阶段确定哪些文件属于静态资源
    • 把静态资源与主页面置于不同域名下:避免请求带上 Cookie

    内容分发网络简称 CDN,指一组分布在各地存储数据副本并可根据就近原则满足数据请求的服务器。其核心特征是 缓存回源,缓存是把资源复制到 CDN服务器里,回源是 资源过期/不存在就向上层服务器请求并复制到 CDN服务器里。

    使用 CDN可降低网络拥塞,提高用户访问响应速度和命中率。构建在现有网络基础上的智能虚拟网络,依靠部署在各地服务器,通过中心平台的调度、负载均衡、内容分发等功能模块,使用户就近获取所需资源,这就是 CDN的终极使命。

    基于 CDN就近原则所带来的优点,可将网站所有静态资源全部部署到 CDN服务器里。那静态资源包括哪些文件?通常来说就是无需服务器产生计算就能得到的资源,例如不常变化的 样式文件脚本文件多媒体文件(字体/图像/音频/视频)等。

    若需单独配置 CDN服务器,可考虑 阿里云OSS网易树帆NOS七牛云Kodo,当然配置起来还需购买该产品对应的 CDN服务。由于篇幅问题,这些配置在购买后会有相关教程,可自行体会,在此就不再叙述了。

    笔者推荐大家首选 网易树帆NOS,毕竟对自家产品还是挺有信心的,不小心给自家产品打了个小广告了,哈哈!

    缓存策略

    该策略主要围绕 浏览器缓存做相关处理,同时也使接入成本最低的 性能优化策略。其显著减少网络传输所带来的损耗,提升网页访问速度,是一种很值得使用的 性能优化策略

    通过下图可知,为了让 浏览器缓存发挥最大作用,该策略尽量遵循以下五点就能发挥 浏览器缓存最大作用。

    • 考虑拒绝一切缓存策略Cache-Control:no-store
    • 考虑资源是否每次向服务器请求Cache-Control:no-cache
    • 考虑资源是否被代理服务器缓存Cache-Control:public/private
    • 考虑资源过期时间Expires:t/Cache-Control:max-age=t,s-maxage=t
    • 考虑协商缓存Last-Modified/Etag

    缓存判断机制

    同时 浏览器缓存也是高频面试题之一,笔者觉得上述涉及到的名词在不同语序串联下也能完全理解才能真正弄懂 浏览器缓存性能优化里起到的作用。

    缓存策略通过设置 HTTP报文实现,在形式上分为 强缓存/强制缓存协商缓存/对比缓存。为了方便对比,笔者将某些细节使用图例展示,相信你有更好的理解。

    强缓存.png

    协商缓存.png

    整个 缓存策略机制很明了, 先走强缓存,若命中失败才走协商缓存。若命中 强缓存,直接使用 强缓存;若未命中 强缓存,发送请求到服务器检查是否命中 协商缓存;若命中 协商缓存,服务器返回304通知浏览器使用 本地缓存,否则返回 最新资源

    有两种较常用的应用场景值得使用 缓存策略一试,当然更多应用场景都可根据项目需求制定。

    • 频繁变动资源:设置 Cache-Control:no-cache,使浏览器每次都发送请求到服务器,配合 Last-Modified/ETag验证资源是否有效
    • 不常变化资源:设置 Cache-Control:max-age=31536000,对文件名哈希处理,当代码修改后生成新的文件名,当HTML文件引入文件名发生改变才会下载最新文件

    渲染层面

    渲染层面的性能优化,无疑是如何让代码 解析更好执行更快。因此笔者从以下五方面做出建议。

    • CSS策略:基于CSS规则
    • DOM策略:基于DOM操作
    • 阻塞策略:基于脚本加载
    • 回流重绘策略:基于回流重绘
    • 异步更新策略:基于异步更新

    上述五方面都是编写代码时完成,充满在整个项目流程的开发阶段里。因此在开发阶段需时刻注意以下涉及到的每一点,养成良好的开发习惯, 性能优化也自然而然被使用上了。

    渲染层面性能优化更多表现在编码细节上,而并非实体代码。简单来说就是遵循某些编码规则,才能将 渲染层面性能优化发挥到最大作用。

    回流重绘策略渲染层面性能优化里占比较重,也是最常规的 性能优化之一。上年笔者发布的掘金小册 《玩转CSS的艺术之美》使用一整章讲解 回流重绘,本章已开通试读,更多细节请戳 这里

    CSS策略
    • 避免出现超过三层的 嵌套规则
    • 避免为 ID选择器添加多余选择器
    • 避免使用 标签选择器代替 类选择器
    • 避免使用 通配选择器,只对目标节点声明规则
    • 避免重复匹配重复定义,关注 可继承属性
    DOM策略
    • 缓存 DOM计算属性
    • 避免过多 DOM操作
    • 使用 DOMFragment缓存批量化 DOM操作
    阻塞策略
    • 脚本与 DOM/其它脚本的依赖关系很强:对 <script>设置 defer
    • 脚本与 DOM/其它脚本的依赖关系不强:对 <script>设置 async
    回流重绘策略
    • 缓存 DOM计算属性
    • 使用类合并样式,避免逐条改变样式
    • 使用 display控制 DOM显隐,将 DOM离线化
    异步更新策略
    • 异步任务中修改 DOM时把其包装成 微任务

    六大指标

    笔者根据 性能优化的重要性和实际性划分出 九大策略六大指标,其实它们都是一条条活生生的 性能优化建议。有些 性能优化建议接不接入影响都不大,因此笔者将 九大策略定位高于 六大指标。针对 九大策略还是建议在开发阶段和生产阶段接入,在项目复盘时可将 六大指标的条条框框根据实际应用场景接入。

    六大指标基本囊括大部分 性能优化细节,可作为 九大策略的补充。笔者根据每条 性能优化建议的特征将 指标划分为以下六方面。

    • 加载优化:资源在加载时可做的性能优化
    • 执行优化:资源在执行时可做的性能优化
    • 渲染优化:资源在渲染时可做的性能优化
    • 样式优化:样式在编码时可做的性能优化
    • 脚本优化:脚本在编码时可做的性能优化
    • V8引擎优化:针对 V8引擎特征可做的性能优化
    加载优化

    六大指标-加载优化.png

    执行优化

    六大指标-执行优化.png

    渲染优化

    六大指标-渲染优化.png

    样式优化

    六大指标-样式优化.png

    脚本优化

    六大指标-脚本优化.png

    V8引擎优化

    六大指标-V8引擎优化.png

    总结

    性能优化作为老生常谈的知识,必然会在工作或面试时遇上。很多时候不是想到某条 性能优化建议就去做或答,而是要对这方面有一个整体认知,知道为何这样设计,这样设计的目的能达到什么效果。

    性能优化不是通过一篇文章就能全部讲完,若详细去讲可能要写两本书的篇幅才能讲完。本文能到给大家的就是一个方向一种态度,学以致用呗,希望阅读完本文会对你有所帮助。

    最后,笔者将本文所有内容整理成一张高清脑图,由于体积太大无法上传,可关注笔者个人公众号 IQ前端并回复 性能优化获取口袋知识图谱吧!

    一份简单够用的 Nginx Location 配置讲解

    $
    0
    0

    前言

    Location 是 Nginx 中一个非常核心的配置,这篇重点讲解一下 Location 的配置问题以及一些注意事项。

    语法

    关于 Location,举个简单的配置例子:

    http { 
      server {
          listen 80;
            server_name www.yayujs.com;
            location / {
              root /home/www/ts/;
              index index.html;
            }
      }
    }

    大致的意思是,当你访问 www.yayujs.com80端口的时候,返回 /home/www/ts/index.html文件。

    我们看下 Location 的具体语法:

    location [ = | ~ | ~* | ^~ ] uri { ... }

    重点看方括号中的 [ = | ~ | ~* | ^~ ],其中 |分隔的内容表示你可能会用到的语法,其中:

    • =表示精确匹配,比如:
    location = /test {
      return 200 "hello";
    }
    
    # /test ok
    # /test/ not ok
    # /test2 not ok
    # /test/2 not ok
    • ~表示区分大小写的正则匹配,比如:
    location ~ ^/test$ {
      [ configuration ] 
    }
    
    # /test ok
    # /Test not ok
    # /test/ not ok
    # /test2 not ok
    • ~*表示不区分大小写的正则匹配
    location ~* ^/test$ {     
        [ configuration ] 
    }
    
    # /test ok
    # /Test ok
    # /test/ not ok
    # /test2 not ok
    • ^~表示 uri 以某个字符串开头
    location ^~ /images/ {    
        [ configuration ] 
    }
    
    # /images/1.gif ok

    而当你不使用这些语法的时候,只写 uri 的时候:

    /表示通用匹配:

    location / {     
        [ configuration ] 
    }
    
    # /index.html ok
    location /test {
        [ configuration ] 
    }
    
    # /test ok
    # /test2 ok
    # /test/ ok

    匹配顺序

    当存在多个 location 的时候,他们的匹配顺序引用 Nginx 官方文档就是:

    A location can either be defined by a prefix string, or by a regular expression. Regular expressions are specified with the preceding “~*” modifier (for case-insensitive matching), or the “~” modifier (for case-sensitive matching). To find location matching a given request, nginx first checks locations defined using the prefix strings (prefix locations). Among them, the location with the longest matching prefix is selected and remembered. Then regular expressions are checked, in the order of their appearance in the configuration file. The search of regular expressions terminates on the first match, and the corresponding configuration is used. If no match with a regular expression is found then the configuration of the prefix location remembered earlier is used.

    If the longest matching prefix location has the “^~” modifier then regular expressions are not checked.

    Also, using the “=” modifier it is possible to define an exact match of URI and location. If an exact match is found, the search terminates. For example, if a “/” request happens frequently, defining “location = /” will speed up the processing of these requests, as search terminates right after the first comparison. Such a location cannot obviously contain nested locations.

    翻译整理后就是:

    location 的定义分为两种:

    • 前缀字符串(prefix string)
    • 正则表达式(regular expression),具体为前面带 ~*~修饰符的

    而匹配 location 的顺序为:

    1. 检查使用前缀字符串的 locations,在使用前缀字符串的 locations 中选择最长匹配的,并将结果进行储存
    2. 如果符合带有 =修饰符的 URI,则立刻停止匹配
    3. 如果符合带有 ^~修饰符的 URI,则也立刻停止匹配。
    4. 然后按照定义文件的顺序,检查正则表达式,匹配到就停止
    5. 当正则表达式匹配不到的时候,使用之前储存的前缀字符串

    再总结一下就是:

    在顺序上,前缀字符串顺序不重要,按照匹配长度来确定,正则表达式则按照定义顺序。

    在优先级上, =修饰符最高, ^~次之,再者是正则,最后是前缀字符串匹配。

    我们举几个简单的例子复习下:

    server {
        location /doc {
            [ configuration A ] 
        }
        location /docu {
            [ configuration B ] 
        }
    }
    
    # 请求 /document 使用 configuration B
    # 虽然 /doc 也能匹配到,但在顺序上,前缀字符串顺序不重要,按照匹配长度来确定
    server {
        location ~ ^/doc {
            [ configuration A ] 
        }
        location ~ ^/docu {
            [ configuration B ] 
        }
    }
    
    # 请求 /document 使用 configuration A
    # 虽然 ~ ^/docu 也能匹配到,但正则表达式则按照定义顺序
    server {
        location ^~ /doc {
            [ configuration A ] 
        }
        location ~ ^/docu {
            [ configuration B ] 
        }
    }
    
    # 请求 /document 使用 configuration A
    # 虽然 ~ ^/docu 也能匹配到,但 ^~ 的优先级更高
    server {
        location /document {
            [ configuration A ] 
        }
        location ~ ^/docu {
            [ configuration B ] 
        }
    }
    
    # 请求 /document 使用 configuration B
    # 虽然 /document 也能匹配到,但正则的优先级更高

    root 与 alias 的区别

    当我们这样设置 root的时候:

    location /i/ {
        root /data/w3;
    }

    当请求 /i/top.gif/data/w3/i/top.gif会被返回。

    当我们这样设置 alias的时候:

    location /i/ {
        alias /data/w3/images/;
    }

    当请求 /i/top.gif/data/w3/images/top.gif会被返回。

    乍一看两者很像,但细一看,就能看出两者的区别,root 是直接拼接 root + location而 alias 是用 alias替换 location,所以 root 中最后的路径里有 /i/,而 alias 中最后的路径里没有 /i/

    所以如果你这样使用 allias 定义一个路径:

    location /images/ {
        alias /data/w3/images/;
    }

    其实使用 root 会更好:

    location /images/ {
        root /data/w3;
    }

    server 和 location 中的 root

    server 和 location 中都可以使用 root,举个例子:

    http { 
      server {
          listen 80;
            server_name www.yayujs.com;
            root /home/www/website/;
            location / {
              root /home/www/ts/;
              index index.html;
            }
      }
    }

    如果两者都出现,是怎样的优先级呢?

    简单的来说,就是就近原则,如果 location 中能匹配到,就是用 location 中的 root 配置,忽略 server 中的 root,当 location 中匹配不到的时候,则使用 server 中的 root 配置。

    系列文章

    博客搭建系列是我至今写的唯一一个偏实战的系列教程,讲解如何使用 VuePress 搭建博客,并部署到 GitHub、Gitee、个人服务器等平台。

    1. 一篇带你用 VuePress + GitHub Pages 搭建博客
    2. 一篇教你代码同步 GitHub 和 Gitee
    3. 还不会用 GitHub Actions ?看看这篇
    4. Gitee 如何自动部署 Pages?还是用 GitHub Actions!
    5. 一份前端够用的 Linux 命令

    微信:「mqyqingfeng」,加我进冴羽唯一的读者群。

    如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者 有所启发,欢迎 star,对作者也是一种鼓励。


    面向微前端,谈谈 JavaScript 隔离沙箱机制的古往今来

    $
    0
    0

    前言

    随着微前端的不断发展、被更多的团队采用,越来越多开始对沙箱这个概念有所了解。 沙箱,即 sandbox,意指一个允许你独立运行程序的虚拟环境,沙箱可以隔离当前执行的环境作用域和外部的其他作用域,外界无法修改该环境内任何信息,沙箱内的东西单独运行,环境间相互不受影响。本文计划谈谈微前端的 JavaScript 隔离,即沙箱机制的古往今来。

    要实现一个 JavaScript 沙箱,可以有很多种分类方式,比如按照具体的实现方式来区分,就至少包含如下:

    1. 基于 Proxy 快照存储 + window 修改的实现
    2. 基于 Proxy 代理拦截 + window 激活/卸载的实现
    3. 基于普通对象快照存储的 window 属性 diff 实现
    4. 基于 iframe + 消息通信的实现
    5. 基于 ShadowRealm 提案的实现
    6. 基于 with + eval 的简单实现
    7. ……

    本文主要考虑沙箱机制在实现时所用到的主要 Web 技术,计划大致分为四类实现方案分别介绍,结合之下,本文目录如下:

    1. 前言
    2. 基于 Proxy 实现的沙箱机制
      1. 简要谈谈 Proxy API
      2. 基于 Proxy 的沙箱实现考虑
      3. 结合微前端框架 qiankun 介绍两类沙箱实现
    3. 基于属性 diff 实现的沙箱机制
    4. 基于 iframe 实现的沙箱机制
      1. 基于 Proxy 及 diff 的沙箱机制边界考虑
      2. 利用 iframe 实现沙箱机制的几点思考
      3. 一段 iframe 沙箱的示例代码
    5. 各类沙箱机制对比
    6. 基于 ES 提案 ShadowRealm API 介绍
      1. 什么是 JavaScript 的运行环境实例
      2. ShadowRealm API 简介
      3. ShadowRealm 的错误捕获与更多应用场景
    7. 总结
    8. 参考

    希望通过我自己的项目实践、阅读代码、提案梳理等方式对 JavaScript 隔离(沙箱机制)进行系统整理,其中会结合一些开源框架的实现来辅助解读,但不会针对微前端框架深入介绍,也不会就某一个沙箱机制的具体细节实现(比如如何构建闭包环境、属性读取、DOM 操作等众多边界处理)进行剖析。

    如果你想了解关于 CSS 样式隔离的内容可以搜索 Shadow DOM 相关内容进一步查阅;如果你想了解微前端的主子应用加载、运行机制,可以参考 single-spa 文档、qiankun 文档、ShadowRealm 提案等内容;如果你想了解文中涉及的一些概念与 API 用法可以在 MDN 进行搜索查阅,大部分均有对应介绍。

    本文在撰写中尽力保证文章的思路流畅和通俗易懂,但由于个人正从事基于微前端方案的开发,可能有些概念会潜意识认为所有读者均已了解,未能详尽每个涉及名词的统一处理或解释,此处针对一些通用的概念进行铺垫:

    1. 主应用:在微前端方案中,区分主子应用,主应用通常负责全局资源的加载、隔离、控制运行,用户登陆信息等全局状态的管理等等,也被称为基座、微前端全局环境等;
    2. 子应用:微前端方案中可以独立加载运行的一个 Web 应用,通常需要一个完备的隔离环境供其加载,文中提到的沙箱激活/卸载也是为其服务,也称微应用;
    3. 沙箱:意指一个允许你独立运行程序的虚拟环境,沙箱可以隔离当前执行的环境作用域和外部的其他作用域,外界无法修改该环境内任何信息,沙箱内的东西单独运行,环境间相互不受影响,英文对应 sandbox,此名词常与 JavaScript 隔离一起使用;
    4. qiankun:一款开源方案,基于  single-spa微前端实现库;

    以下开始正文。

    基于 Proxy 的沙箱机制

    Proxy 是当下做 JavaScript 隔离用到的最主要的手段之一,接下来我们详细说说基于 Proxy 的沙箱机制。

    简要谈谈 Proxy API

    Proxy 是一个标准 Web API,在 ES6 版本中被推出,这个对象可以用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等),我们可以通过一个简单的例子来解释说明 Proxy 的作用:

    const handler = {
        get: function(obj, prop) {
            return prop in obj ? obj[prop] : 37;
        }
    };
    
    const p = new Proxy({}, handler);
    p.a = 1;
    p.b = undefined;
    
    console.log(p.a, p.b);      // 1, undefined
    console.log('c' in p, p.c); // false, 37

    在上例中,我们定义了一个 handler,其中包含一个 get 拦截器,它的作用是在属性查找时,如果对象对应属性不存在时返回数值 37,此后我们通过 Proxy 对一个空对象进行了代理,分别打印了其中的 a、b、c 属性,可以发现,其中 c 属性由于不存在而返回了 37。

    基于 Proxy 的沙箱实现考虑

    既然 Proxy 可以用于代理对象,那么我们同样可以用其代理 window——Web 应用运行中最重要的上下文环境。每个 Web 应用都会与 window 交互,无数的 API 也同样挂靠在 window 上,要达到允许独立运行的微前端环境,首先需要 window 隔开。

    在采用 Proxy 作为沙箱机制方案时,主要还是基于 get、set、has、getOwnPropertyDescriptor 等关键拦截器对 window 进行代理拦截(如下如有涉及代码,我们主要关注 get 与 set 两类拦截器)。为了让沙箱的代理拦截完备,除了 window 外,我们通常都需要关注几方面,比如一些难以代理(或者说没必要代理)的 Web API,如 Array、Number、Promise 等,此外还需要保证通过 with、eval、new Function 等方式执行的代码作用域不会逃逸,动态加载的 JavaScript 代码也算一个。

    谈到这里,我们首先看看通过 Proxy 进行属性查找时的一些处理逻辑。除了在拦截器中进行一些常规的无需拦截 case 判断外,还需要对 Symbol.unscopables 属性 get 拦截器的返回值做些定义,以方便 with 等方式下代码的执行作用域正常处理,如下是个简单的例子:

    const unscopables = {
      Array: true,
      Object: true,
      String: true,
      Promise: true,
      requestAnimationFrame: true,
      ...
    };
    
    // ...
    
    {
      get: (target: FakeWindow, p: PropertyKey): any => {
        // Symbol.unscopables 属性
        if (p === Symbol.unscopables) return unscopables;
    
        // 无需拦截的 Web API
        if (p === 'eval') {
          return eval;
        }
      }
    }

    如上代码中,关于 eval 的拦截判断很好理解,这里我们停下简要介绍一下一个概念: Symbol.unscopables

    Symbol.unscopables属性,指用于指定对象值,其对象自身和继承的从关联对象的 with 环境绑定中排除的属性名称。当我们在 unscopables对象上将属性设置为 true,将使其 unscopable并且因此该属性也将不会在词法环境变量中出现。我们来看一个简单例子,以了解其效果:

    const object1 = {
      property1: 42
    };
    
    object1[Symbol.unscopables] = {
      property1: true
    };
    
    with (object1) {
      console.log(property1);
      // expected output: Error: property1 is not defined
    }
    

    注:在微前端环境下,通常需要对一些全局变量与属性进行更全面的梳理,此处可以参考 qiankun 的实现 https://github.com/umijs/qiankun/blob/dbbc9acdb0733b3ab28e0470c969d65b57653ff0/src/sandbox/proxySandbox.ts#L255

    结合微前端框架 qiankun 介绍两类沙箱实现

    微前端框架 qiankun 中一共存在三类沙箱,基于 Proxy 实现方式不同以及是否支持多实例,可以分为两类:

    1. 支持子应用单实例沙箱(LegacySandbox)
    2. 支持子应用多实例沙箱(ProxySandbox)

    当我们只针对全局运行环境进行代理赋值记录,而不从中取值,那么这样的沙箱只是作为我们记录变化的一种手段,而实际操作仍在主应用运行环境中对 window 进行了读写,因此这类沙箱也只能支持单实例模式,qiankun 在实现上将其命名为 LegacySandbox。

    我们先假设我们的沙箱实现上包含这几个变量(此处以 qiankun 实现为例):

    /** 沙箱期间新增的全局变量 */
    private addedPropsMapInSandbox = new Map<PropertyKey, any>();
    
    /** 沙箱期间更新的全局变量 */
    private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
    
    /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
    private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

    这类沙箱的激活与卸载思路可以通过如下两个函数代码解释。首先是激活函数,当沙箱被激活时,我们通过曾经记录好的更新过的全局变量(也可以称为快照)来还原子应用所需要的沙箱环境(即上下文):

    active() {
      if (!this.sandboxRunning) {
        this.currentUpdatedPropsValueMap.forEach(
           (v, p) => this.setWindowProp(p, v)
        );
      }
    
      this.sandboxRunning = true;
    }
    

    等到需要卸载时,沙箱需要做两件事,一是将子应用运行时修改过的全局变量还原,另一个是删除子应用运行时新增的全局变量:

    inactive() {
      this.modifiedPropsOriginalValueMapInSandbox.forEach(
        (v, p) => this.setWindowProp(p, v)
      );
      this.addedPropsMapInSandbox.forEach(
        (_, p) => this.setWindowProp(p, undefined, true)
      );
    
      this.sandboxRunning = false;
    }
    

    注:详尽代码可以参考 qiankun 实现 https://github.com/umijs/qiankun/blob/dbbc9acdb0733b3ab28e0470c969d65b57653ff0/src/sandbox/legacy/sandbox.ts#L51-L73

    如上所述,LegacySandbox 的思路在于虽然建立了沙箱代理,但在子应用运行过程中,所有的赋值仍旧会直接操作 window 对象,代理所做的事情就是记录变化(形成快照);而针对激活和卸载,沙箱会在激活时还原子应用的状态,而卸载时还原主应用的状态,以此达到沙箱隔离的目的。

    LegacySandbox 由于会修改 window 对象,在多个实例运行时肯定会存在冲突,因此,该沙箱模式只能在单实例场景下使用,而当我们需要同时起多个实例时,ProxySandbox 便登场了。

    ProxySandbox 的方案是同时用 Proxy 给子应用运行环境做了 get 与 set 拦截。沙箱在初始构造时建立一个状态池,当应用操作 window 时,赋值通过 set 拦截器将变量写入状态池,而取值也是从状态池中优先寻找对应属性。由于状态池与子应用绑定,那么运行多个子应用,便可以产生多个相互独立的沙箱环境。

    由于取值赋值均在建立的状态池上操作,因此,在第一种沙箱环境下激活和卸载需要做的工作,这里也就不需要了。关于状态池的设计,可以参考代码 https://github.com/umijs/qiankun/blob/dbbc9acdb0733b3ab28e0470c969d65b57653ff0/src/sandbox/proxySandbox.ts#L81

    基于属性 diff 的沙箱机制

    由于 Proxy 为 ES6 引入的 API,在不支持 ES6 的环境下,我们可以通过一类原始的方式来实现所要的沙箱,即利用普通对象针对 window 属性值构建快照,用于环境的存储与恢复,并在应用卸载时对 window 对象修改做 diff 用于子应用环境的更新保存。在 qiankun 中也有该降级方案,被称为 SnapshotSandbox。当然,这类沙箱同样也不能支持多实例运行,原因也相同。

    这类方案的主要思路与 LegacySandbox 有些类似,同样主要分为激活与卸载两个部分的操作。

    // iter 为一个遍历对象属性的方法
    
    active() {
      // 记录当前快照
      this.windowSnapshot = {} as Window;
      iter(window, (prop) => {
        this.windowSnapshot[prop] = window[prop];
      });
    
      // 恢复之前的变更
      Object.keys(this.modifyPropsMap).forEach((p: any) => {
        window[p] = this.modifyPropsMap[p];
      });
    
      this.sandboxRunning = true;
    }
    

    在激活时首先将 window 属性遍历存储起来(作为还原 window 所需的快照),然后在 window 上恢复子应用所需的属性变更,是的,直接修改 window 对象。

    inactive() {
      this.modifyPropsMap = {};
    
      iter(window, (prop) => {
        if (window[prop] !== this.windowSnapshot[prop]) {
          // 记录变更,恢复环境
          this.modifyPropsMap[prop] = window[prop];
          window[prop] = this.windowSnapshot[prop];
        }
      });
    
      this.sandboxRunning = false;
    }
    

    而等到卸载时,将此时 window 上所包含的属性遍历存储起来(作为以后还原子应用所需的快照),然后从先前保存的 window 对象中将环境恢复。

    由于未使用到 Proxy,且只利用 Object 的操作来实现,这个沙箱机制是三类机制中最简单的一种。

    注:SnapshotSandbox 参考代码 https://github.com/umijs/qiankun/blob/dbbc9acdb0733b3ab28e0470c969d65b57653ff0/src/sandbox/snapshotSandbox.ts

    基于 iframe 的沙箱机制

    基于 Proxy 及 diff 的沙箱机制边界考虑

    不论是基于 Proxy 还是 diff,其沙箱机制的方案都是通过模拟和代理来实现一个环境隔离的沙箱,只是所有 API 不同。由于是模拟,因此不可避免的在使用中需要考虑一些边界 case,我们简单来看两个问题。首先看一段代码:

    var foo = "hello";
    
    function foo() {}
    

    如上代码大家都很熟悉,在无沙箱环境下两种写法可以自动提升为  [window.foo](http://window.foo),但是 Proxy 沙箱下这类代码就需要注意,由于代码执行作用域发生了变更,所以生效的环境不再是全局 window,这时通过 proxy 的 get 拦截器大概率就会返回 undefined,于是便会产生疑问“我本地运行是有值的,为什么到微前端里就 undefined 了呢?”,对于后者,诸如 qiankun 框架中可以通过 window.proxy 获取对应上下文来取值达到目的,但前者由于限制,必须显式的定义为 window.foo 否则无法获取。

    对于不了解微前端框架的同学来说,这无疑会增加了解成本。对于同类问题,我们再看一个问题描述:

    我的子应用新建了一个 iframe 来做些 JavaScript 逻辑,但在里面通过 window.parent.xxx 无法获取子应用 window 上的全局变量? 但这个变量实际上是存在的,我在子应用中可以把它打印出来的。

    造成这个问题的原因类似,由于 iframe 中的 JavaScript 不在沙箱里执行,会读到外面真实的 window 上。而当你在子应用中定义了一个全局变量,方法是在沙箱里面拦截定义的,也就是方法实现写在沙箱里、方法调用读在沙箱外。解决方法有两种:

    1. 把变量做白名单处理,强制写在外面真实的 window 上;
    2. 在 iframe 中用 window.parent.proxy 来获取对应的变量;

    以上所述的问题源自模拟,既然是模拟那么就可能存在难以抹平的边界情况,那么有没有更好一些的解决方案呢,iframe 虽然有那么多缺点,但他就是浏览器原生提供的一个隔离环境呢,有可能吗?

    常规思路下,大家想到的 iframe 都是在页面内起一个 iframe 元素,然后将需要加载的 url 填入进行加载,由于体验上的割裂,这种方式并不为大家认可,这也是为什么基于 Proxy 和 diff 的沙箱机制被提出的原因。

    让我们再想想,iframe 都有什么优点?

    1. 使用简单,一个 url 即可,不需要其他微前端方案那样手动写入很多钩子以适配在微前端环境中的运行;
    2. 利用浏览器的设计,可以实现样式、DOM、JavaScript 代码执行的完美隔离;
    3. 页面原则上可以起无数多个 iframe 标签来加载应用,所以可以实现多应用共存;
    4. 通过 iframe 实现的沙箱可以绕过 eval 执行的限制,比如当我们的代码中使用了原生 es modules 的写法时(eval 中不支持 import()),如果不做转译,代码便会抛出异常;

    基于这个思路,如果我们不用 iframe 来加载应用,而是只将其作为一个 JavaScript 运行环境,问题是不是就解决了?

    利用 iframe 实现沙箱机制的几点思考

    我们知道,iframe 标签可以创造一个独立的浏览器级别的运行环境,该环境与主环境隔离,并有自己的 window 上下文;在通信机制上,也可以利用 postMessage 等 API 与宿主环境进行通信。具体来说,在执行 JavaScript 代码上,我们不需要做什么处理,但是要让 iframe 成为符合我们要求的沙箱,还需要重新设计。其中,和沙箱机制有关的几点包含:

    • 应用间运行时隔离;
    • 应用间通信;
    • 路由劫持;

    我们一一来看看。 首先,是对运行环境的代理与隔离,这也是大多数沙箱必备的基础之一。由于利用了 iframe,所以我们几乎不用担心 JavaScript 的代码运行会给沙箱外环境带来什么影响,因为在 iframe 中运行的 JavaScript 代码都是直接操作 iframe 的 window 上下文,但这里却需要考虑另一方面:如何将一些必要的操作传递出沙箱,因此也需要用到 Proxy 来做一些共享,比如路由、DOM操作等,这涉及到 location、history 等对象。通过将主应用环境下的对象透传给 iframe 中 JavaScript 使用,可以保证子应用在执行操作时,返回前进等操作可以同步到浏览器 top level 层面。此外,对于动态执行的 JavaScript 脚本(比如动态增加一个 script 元素),也需要单独考虑限制作用域,以使 script 中代码在执行时可以对应上具体的全局环境,这里可以通过为 script 包裹一层以锁定作用域内的部分全局变量取值:

    const scriptInstance = document.createElement('script');
    const script = `(function(window, self, document, location, history) {
        ${scriptString}\n
      }).bind(window.proxyWindow)(
        window.proxyWindow,
        window.proxyWindow,
        window.proxyShadowDom,
        window.proxyLocation,
        window.proxyHistory,
      );`;
    
    scriptInstance.text = script;
    document.head.appendChild(scriptInstance);
    

    其他方面,由于上文已经提到过关于 Proxy 对 get/set 拦截器的实现,本部分不再赘述。

    刚刚提到的 DOM 操作,我们在这里多做一些介绍。当 JavaScript 操作 DOM 时,我们肯定需要让其中的操作透传到 iframe 外部进行实现,因为 iframe 里面我们不构建 DOM。如果想在隔离方案上一步到位,这里可以使用 Shadow DOM 作为样式隔离的方案,来构建子应用渲染所需的 DOM 结构,而回到 DOM 操作本身,依旧是通过 Proxy 对 iframe document 进行拦截和替换来实现的,这里依据你的样式隔离方案,来决定 document 究竟是指向主应用中的 Shadow DOM Root 节点,还是其他代理的 document 对象。此外,诸如 MutationObserver 这类的操作也需要通过代理保证在主应用上进行。

    其次,再说说通信。一个完备的微前端方案需要考虑主子应用间的通信(与沙箱的通信),这样才可以对框架内的的全局状态或者子应用状态进行感知与响应,我们从同域 iframe 环境看起。

    通过如下代码我们可以构建一个同域的 iframe 元素,此时,iframe 内外通信并不会存在障碍,通过各自 window 便能方便的获取对应属性值;因为是同域环境,从中取出对应的 contentWindow便可以对 iframe 内容属性进行随意读取,而与此同时还与外部环境隔离。

    const iframe = document.createElement('iframe',{url:'about:blank'});
    document.body.appendChild(iframe);
    const sandboxGlobal = iframe.contentWindow;

    而如果要单独构建通信机制,也可以利用自定义 props、event 等方式实现,或者通过 Web API 诸如 postMessage 或者 BroadcastChannel 来实现,关于此部分我在曾经的一篇文章中稍有提及,感兴趣的话可以查看《 Service Worker 实践指南》。

    说回路由状态,要保证 JavaScript 沙箱环境内与主应用路由状态保持一致,我们有两种实现方案:

    1. 让 JavaScript 沙箱内路由变更操作在主应用环境生效;
    2. 同步沙箱内路由变化至主应用环境;

    其中,针对第一种情况,我们需要做的是将诸如 location 、history 等变量代理到沙箱环境中,在这种情况下,因为我们不关心 iframe 自身的路由变化,便可以自由设置 src 属性,比如 about:blank的方式来构建 iframe,而在沙箱实现上我们可以通过前述的 Proxy 来拦截实现。

    但稍微考虑下实际生产环境便会发现,第一种情况存在的限制较多,最基本的便是对沙箱内网络请求发送的处理,所以这就需要我们考虑第二种情况的实现,在这种操作下,我们的路由变化会同步到 iframe 上下文,所以我们需要针对 iframe 路由添加一个监听器,在监听到变化时处理主应用的路由,以实现两者路由同步。当然,这种情况下,我们需要针对主应用所在域名设计一个 iframe 的同域方案,比如同一域名+自定义 path 或者 hash 的实现就很简单易懂,这样也不存在跨域限制,此处不再展开。

    一段 iframe 沙箱的示例代码

    以下简单写了一个 iframe 沙箱的实现伪代码,核心依旧在 window 隔离与共享对象的处理上,主要的实现手段依旧是完善 Proxy 的 get/set 拦截器:

    class SandboxWindow {
        constructor(context, frameWindow) {
            return new Proxy(frameWindow, {
                get(target, name) {
                    if (name in context) {
                        return context[name];
                    } else if(typeof target[name] === 'function' && /^[a-z]/.test(name) ){
                        return target[name].bind && target[name].bind(target);
                    } else {
                        return target[name];
                    }
                },
                set(target, name, value) {
                    if (name in context) {
                        return context[name] = value;
                    }
                    target[name] = value;
                }
            })
        }
    }
    
    // 需要全局共享的变量
    const context = { 
        document: window.document, 
        history: window.history, 
        location: window.location,
    }
    
    // 创建 iframe
    const userInputUrl = '';
    const iframe = document.createElement('iframe',{url: userInputUrl});
    document.body.appendChild(iframe);
    const sandboxGlobal = iframe.contentWindow;
    
    // 创建沙箱
    const newSandboxWindow = new SandboxWindow(context, sandboxGlobal); 
    

    但需要注意的是,iframe 方案下,JavaScript 沙箱只是其中一部分,还需要通过完备的 HTML/JavaScript 代码拆分等方案辅助达到微前端环境的目的,这部分实现可参考 kuitos 的开源库 import-html-entry;同样的,之前的几类沙箱方案也需要考虑与这些方案组合。

    注:在实现上,如果需要区分 iframe 与主应用环境,可以通过代码 window.parent !== window进行判断。

    各类沙箱机制对比

    通过对比 Proxy 的两类实现、属性 diff 的一种实现以及 iframe 实现方案,可以发现几类沙箱的主要特点在于(以下部分方案用 qiankun 中对三类沙箱的命名方式作为沙箱机制名称)

     多实例运行语法兼容不污染全局环境(主应用)
    LegacySanbox
    ProxySandbox
    SnapshotSandbox
    iframe

    基于 ES 提案 ShadowRealm 实现

    ShadowRealm 是一个 ECMAScript 标准提案,旨在创建一个独立的全局环境,它的全局对象包含自己的内建函数与对象(未绑定到全局变量的标准对象,如 Object.prototype 的初始值),有自己独立的作用域,方案当前处于 stage 3 阶段。提案地址 https://github.com/tc39/proposal-shadowrealm

    什么是 JavaScript 的运行环境实例

    谈及提案之前,我们简单来看看什么是 Realm,下面是 Alex 附上的一个例子:

    <body><iframe></iframe><script>
        const win = frames[0].window;
        console.assert(win.globalThis !== globalThis); // (A)
        console.assert(win.Array !== Array); // (B)</script></body>

    在前面 iframe 沙箱机制中我们也有介绍,由于每个 iframe都有一个独立的运行环境,于是在执行时,当前 html 中的全局对象肯定与 iframe的全局对象不相同(A),类似的,全局对象上的 Arrayiframe中获取到的 Array也不同(B)。

    这就是 realm,一个 JavaScript 运行环境(JavaScript platform)实例:包含其所必须的全局环境及内建函数等。

    ShadowRealm API 简介

    ShadowRealm API 由一个包含如下函数签名的类实现:

    declare class ShadowRealm {
      constructor();
      evaluate(sourceText: string): PrimitiveValueOrCallable;
      importValue(specifier: string, bindingName: string): Promise<PrimitiveValueOrCallable>;
    }

    每个 ShadowRealm实例都有自己独立的运行环境实例,在 realm 中,提案提供了两种方法让我们来执行运行环境实例中的 JavaScript 代码:

    • .evaluate():同步执行代码字符串,类似 eval()
    • .importValue():返回一个 Promise对象,异步执行代码字符串。

    通过 evaluate 执行代码与 eval 类似,比如:

    const sr = new ShadowRealm();
    console.assert(
      sr.evaluate(`'ab' + 'cd'`) === 'abcd'
    );

    但存在一些细微的差别,比如执行作用域、调用方式以及传值类型等。例如,如果 .evaluate()返回一个函数,则该函数会被包装,这样我们就可以从外部调用它,而逻辑在 ShadowRealm 中运行,我们可以通过观察下面的 console.assert 来效果:

    globalThis.realm = 'incubator realm';
    
    const sr = new ShadowRealm();
    sr.evaluate(`globalThis.realm = 'child realm'`);
    
    const wrappedFunc = sr.evaluate(`() => globalThis.realm`);
    console.assert(wrappedFunc() === 'child realm');

    说到另一个 API .importValue(),我们可以利用它导入一个外部模块,它会通过一个 Promise 异步返回其执行内容,和 .evalute()函数一样,这个函数被包装,以允许我们在外部调用,而实际代码在 ShadowRealm 中执行,我们可以看看下面这个例子,很好的解释了这个 API 的功能:

    // main.js
    const sr = new ShadowRealm();
    const wrappedSum = await sr.importValue('./my-module.js', 'sum');
    console.assert(wrappedSum('hi', ' ', 'folks', '!') === 'hi folks!');
    
    // my-module.js
    export function sum(...values) {
      return values.reduce((prev, value) => prev + value);
    }

    ShadowRealm 的错误捕获与更多应用场景

    ShadowRealm API 提案暂未针对错误捕获做详细设计,整体看上去比较简洁,因为这些在未来还有可能变化,以下为 Alex 针对当前提案下代码执行错误给出的两个例子,可以看出其中并不包含错误的原始调用堆栈等:

    > new ShadowRealm().evaluate('someFunc(')
    SyntaxError: Unexpected end of script> new ShadowRealm().evaluate(`throw new RangeError('The message')`)
    TypeError: Error encountered during evaluation

    由于没有实践经历,这里仅对 ShadowRealm 提案及相关概念进行了简要介绍,但可以看出,这个提案的落地可能对于一个更完美的 JavaScript 沙箱设计有所帮助,当然,这个提案的应用场景远不止此,比如:

    • Web 应用诸如 IDE或绘图等程序可以运行第三方代码,允许其以插件或者配置的方式引入;
    • 利用 ShadowRealms建立一个可编程环境,来运行用户的代码;
    • 服务器可以在 ShadowRealms中运行第三方代码;
    • 在 ShadowRealms 中可以运行测试运行器(Test Runner),这样外部的 JS 执行环境不会受到影响,并且每个套件都可以在新环境中启动(这有助于提高可复用性),这种场景类似于微前端的 JavaScript 沙箱;
    • 网页抓取和网页应用测试等;

    总结

    如果按照沙箱机制在实现时所用到的主要 Web 技术不同,当下已经论证、开源或者存在实现可能性的 JavaScript 沙箱机制可以分为以下几类:

    1. 基于 ES6 API Proxy 实现
    2. 基于属性 diff 实现
    3. 基于 iframe 实现
    4. 基于 ES 提案 ShadowRealm 实现

    本文基于个人项目实践、阅读代码梳理等方式对每类沙箱机制均进行了介绍,部分引用了 qiankun 的代码实现,部分写了伪代码解释,部分引用了最新 ECMAScript 提案示例,但仍未能详尽每一处细节,比如没有针对微前端框架深入介绍,也不会就某一个沙箱机制的具体细节实现(比如如何构建闭包环境、属性读取的边界处理等)进行剖析,但这些对于从更大的层面了解微前端机制都不可或缺。

    如果你想了解关于 CSS 样式隔离的内容可以搜索 Shadow DOM 相关内容进一步查阅;如果你想了解微前端的主子应用加载、运行机制,可以参考 single-spa 文档、qiankun 文档、ShadowRealm 提案等内容;如果你想了解文中涉及的一些概念与 API 用法可以在 MDN 进行搜索查阅,大部分均有对应介绍。

    参考

    1. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
    2. https://single-spa.js.org/
    3. https://github.com/umijs/qiankun
    4. https://tsejx.github.io/javascript-guidebook/standard-built-in-objects/fundamental-objects/symbol/unscopables/
    5. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol/unscopables
    6. https://juejin.cn/post/6981374562877308936
    7. https://micro-frontends.org/
    8. https://2ality.com/2022/04/shadow-realms.html
    9. https://github.com/tc39/proposal-shadowrealm
    10. https://qiankun.umijs.org/guide

    H5与Native交互之JSBridge技术

    $
    0
    0

    做过混合开发的很多人都知道Ionic和PhoneGap之类的框架,这些框架在web基础上包了一层Native,然后通过Bridge技术使得js可以调用视频、位置、音频等功能。本文就是介绍这层Bridge的交互原理,通过阅读本文你可以了解到js与ios及android底层的通讯原理及JSBridge的封装技术及调试方法。

    一、原理篇

    下面分别介绍IOS和Android与Javascript的底层交互原理

    IOS

    在讲解原理之前,首先来了解下iOS的UIWebView组件,先来看一下苹果官方的介绍:

    You can use the UIWebView class to embed web content in your application. To do so, you simply create a UIWebView object, attach it to a window, and send it a request to load web content. You can also use this class to move back and forward in the history of webpages, and you can even set some web content properties programmatically.

    上面的意思是说UIWebView是一个可加载网页的对象,它有浏览记录功能,且对加载的网页内容是可编程的。说白了UIWebView有类似浏览器的功能,我们使用可以它来打开页面,并做一些定制化的功能,如可以让js调某个方法可以取到手机的GPS信息。

    但需要注意的是,Safari浏览器使用的浏览器控件和UIwebView组件并不是同一个,两者在性能上有很大的差距。幸运的是,苹果发布iOS8的时候,新增了一个WKWebView组件,如果你的APP只考虑支持iOS8及以上版本,那么你就可以使用这个新的浏览器控件了。

    原生的UIWebView类提供了下面一些属性和方法

    属性:

    • loading:是否处于加载中
    • canGoBack:A Boolean value indicating whether the receiver can move backward. (只读)
    • canGoForward:A Boolean value indicating whether the receiver can move forward. (只读)
    • request:The URL request identifying the location of the content to load. (read-only)

    方法:

    • loadData:Sets the main page contents, MIME type, content encoding, and base URL.
    • loadRequest:加载网络内容
    • loadHTMLString:加载本地HTML文件
    • stopLoading:停止加载
    • goBack:后退
    • goForward:前进
    • reload:重新加载
    • stringByEvaluatingJavaScriptFromString:执行一段js脚本,并且返回执行结果

    Native(Objective-C或Swift)调用Javascript方法

    Native调用Javascript语言,是通过 UIWebView组件的 stringByEvaluatingJavaScriptFromString方法来实现的,该方法返回js脚本的执行结果。

    // Swift
    webview.stringByEvaluatingJavaScriptFromString("Math.random()")
    // OC
    [webView stringByEvaluatingJavaScriptFromString:@"Math.random();"];

    从上面代码可以看出它其实就是调用了 window下的一个对象,如果我们要让native来调用我们js写的方法,那这个方法就要在 window下能访问到。但从全局考虑,我们只要暴露一个对象如JSBridge对native调用就好了,所以在这里可以对native的代码做一个简单的封装:

    //下面为伪代码
    webview.setDataToJs(somedata);
    webview.setDataToJs = function(data) {
     webview.stringByEvaluatingJavaScriptFromString("JSBridge.trigger(event, data)")
    }

    Javascript调用Native(Objective-C或Swift)方法

    反过来,Javascript调用Native,并没有现成的API可以直接拿来用,而是需要间接地通过一些方法来实现。UIWebView有个特性:在UIWebView内发起的所有网络请求,都可以通过delegate函数在Native层得到通知。这样,我们就可以在UIWebView内发起一个自定义的网络请求,通常是这样的格式:jsbridge://methodName?param1=value1&param2=value2

    于是在UIWebView的delegate函数中,我们只要发现是jsbridge://开头的地址,就不进行内容的加载,转而执行相应的调用逻辑。

    发起这样一个网络请求有两种方式:1. 通过localtion.href;2. 通过iframe方式;
    通过location.href有个问题,就是如果我们连续多次修改window.location.href的值,在Native层只能接收到最后一次请求,前面的请求都会被忽略掉。

    使用iframe方式,以唤起Native APP的分享组件为例,简单的封闭如下:

    var url = 'jsbridge://doAction?title=分享标题&desc=分享描述&link=http%3A%2F%2Fwww.baidu.com';
    var iframe = document.createElement('iframe');
    iframe.style.width = '1px';
    iframe.style.height = '1px';
    iframe.style.display = 'none';
    iframe.src = url;
    document.body.appendChild(iframe);
    setTimeout(function() {
        iframe.remove();
    }, 100);

    然后Webview就可以拦截这个请求,并且解析出相应的方法和参数。如下代码所示:

    func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
            print("shouldStartLoadWithRequest")
            let url = request.URL
            let scheme = url?.scheme
            let method = url?.host
            let query = url?.query
            if url != nil && scheme == "jsbridge" {
                print("scheme == \(scheme)")
                print("method == \(method)")
                print("query == \(query)")
    
                switch method! {
                    case "getData":
                        self.getData()
                    case "putData":
                        self.putData()
                    case "gotoWebview":
                        self.gotoWebview()
                    case "gotoNative":
                        self.gotoNative()
                    case "doAction":
                        self.doAction()
                    case "configNative":
                        self.configNative()
                    default:
                        print("default")
                }
                return false
            } else {
                return true
            }
        }

    Android

    在android中,native与js的通讯方式与ios类似,ios中的通过schema方式在android中也是支持的。

    javascript调用native方式

    目前在android中有三种调用native的方式:

    1.通过schema方式,使用 shouldOverrideUrlLoading方法对url协议进行解析。这种js的调用方式与ios的一样,使用iframe来调用native代码。
    2.通过在webview页面里直接注入原生js代码方式,使用 addJavascriptInterface方法来实现。
    在android里实现如下:

    class JSInterface {
        @JavascriptInterface //注意这个代码一定要加上
        public String getUserData() {
            return "UserData";
        }
    }
    webView.addJavascriptInterface(new JSInterface(), "AndroidJS");

    上面的代码就是在页面的window对象里注入了 AndroidJS对象。在js里可以直接调用

    alert(AndroidJS.getUserData()) //UserDate

    3.使用prompt,console.log,alert方式,这三个方法对js里是属性原生的,在android webview这一层是可以重写这三个方法的。一般我们使用prompt,因为这个在js里使用的不多,用来和native通讯副作用比较少。

    class YouzanWebChromeClient extends WebChromeClient {
        @Override
        public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
            // 这里就可以对js的prompt进行处理,通过result返回结果
        }
        @Override
        public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
    
        }
        @Override
        public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
    
        }
    
    }

    Native调用javascript方式

    在android里是使用webview的 loadUrl进行调用的,如:

    // 调用js中的JSBridge.trigger方法
    webView.loadUrl("javascript:JSBridge.trigger('webviewReady')");

    二、库的封装

    js调用native的封装

    上面我们了解了js与native通讯的底层原理,所以我们可以封装一个基础的通讯方法 doCall来屏蔽android与ios的差异。

    YouzanJsBridge = {
        doCall: function(functionName, data, callback) {
            var _this = this;
            // 解决连续调用问题
            if (this.lastCallTime && (Date.now() - this.lastCallTime) < 100) {
                setTimeout(function() {
                    _this.doCall(functionName, data, callback);
                }, 100);
                return;
            }
            this.lastCallTime = Date.now();
            data = data || {};
            if (callback) {
                $.extend(data, { callback: callback });
            }
            if (UA.isIOS()) {
                $.each(data, function(key, value) {
                    if ($.isPlainObject(value) || $.isArray(value)) {
                        data[key] = JSON.stringify(value);
                    }
                });
                var url = Args.addParameter('youzanjs://' + functionName, data);
                var iframe = document.createElement('iframe');
                iframe.style.width = '1px';
                iframe.style.height = '1px';
                iframe.style.display = 'none';
                iframe.src = url;
                document.body.appendChild(iframe);
                setTimeout(function() {
                    iframe.remove();
                }, 100);
            } else if (UA.isAndroid()) {
                window.androidJS && window.androidJS[functionName] && window.androidJS[functionName](JSON.stringify(data));
            } else {
                console.error('未获取platform信息,调取api失败');
            }
        }
    }

    上面android端我们使用了addJavascriptInterface方法来注入一个AndroidJS对象。

    项目通用方法抽象

    在项目的实践中,我们逐渐抽象出一些通用的方法,这些方法基本上都是可以满足项目的需求。如下所示:

    1.getData(datatype, callback, extra) H5从Native APP获取数据

    使用场景:H5需要从Native APP获取某些数据的时候,可以调用这个方法。

    参数类型是否必须示例值说明
    datatypeStringuserInfo数据类型
    callbackFunction回调函数
    extraObject传递给Native APP的数据对象

    示例代码:

    JSBridge.getData('userInfo',function(data) {
        console.log(data);
    });

    2.putData(datatype, data) H5告诉Native APP一些数据

    使用场景:H5告诉Native APP一些数据,可以调用这个方法。

    参数类型是否必须示例值说明
    datatypeStringuserInfo数据类型
    dataObject{ username: 'zhangsan', age: 20 }传递给Native APP的数据对象

    示例代码:

    JSBridge.putData('userInfo', {
        username: 'zhangsan',
        age: 20
    });

    3.gotoWebview(url, page, data) Native APP新开一个Webview窗口,并打开相应网页

    参数类型是否必须示例值说明
    urlStringhttp://www.youzan.com网页链接地址,一般都只要传递URL参数就可以了
    pageStringweb网页page类型,默认为web
    dataObject额外参数对象

    示例代码:

    // 示例1:打开一个网页
    JSBridge.gotoWebview('http://www.youzan.com');
    
    // 示例2:打开一个网页,并且传递额外的参数给Native APP
    JSBridge.gotoWebview('http://www.youzan.com', 'goodsDetail', {
        goods_id: 10000,
        title: '这是商品的标题',
        desc: '这是商品的描述'
    });

    4.gotoNative(page, data) 从H5页面跳转到Native APP的某个原生界面

    参数类型是否必须示例值说明
    pageStringloginPageNative页面标示符,例如loginPage
    dataObject{ username: 'zhangsan', age: 20 }额外参数对象

    示例代码:

    // 示例1:打开Native APP登录页面
    JSBridge.gotoNative('loginPage');
    
    // 示例2:打开Native APP登录页面,并且传递用户名给Native APP
    JSBridge.gotoNative('loginPage', {
        username: '张三'
    });

    5.doAction(action, data) 功能上的一些操作

    参数类型是否必须示例值说明
    actionStringcopy操作功能类型,例如分享、复制
    dataObject{ content: '这是要复制的内容' }额外参数

    示例代码:

    // 示例1:调用Native APP复制一段文本到剪切板
    JSBridge.doAction('copy', {
        content: '这是要复制的内容'
    });
    
    // 示例2:调用Native APP的分享组件,分享当前网页到微信
    JSBridge.doAction('share', {
        title: '分享标题',
        desc: '分享描述',
        link: 'http://www.youzan.com',
        imgs_url: 'http://wap.koudaitong.com/v2/common/url/create?type=homepage&index%2Findex=&kdt_id=63077&alias=63077'
    });

    三、调试篇

    使用Safari进行UIWebView的调试

    (1)首先需要打开Safari的调试模式,在Safari的菜单中,选择“Safari”→“Preference”→“Advanced”,勾选上“Show Develop menu in menu bar”选项,如下图所示。
    2-1
    (2)打开真机或iPhone模拟器的调试模式,在真机或iPhone模拟器中打开设置界面,选择“Safari”→“高级”→“Web检查器”,选择开启即可,如下图所示。
    2-2
    (3)将真机通过USB连上电脑,或者开启模拟器,Safari的“Develop”菜单下便会多出相应的菜单项,如图所示。

    Paste_Image.png

    (4)Safari连接上UIWebView之后,我们就可以直接在Safari中直接修改HTML、CSS,以及调试Javascript。

    Paste_Image.png

    四、参考链接

    本文由 @kk @劲风 共同创作

    vue快速入门的三个小实例

    $
    0
    0

    1.前言

    用vue做项目也有一段时间了,之前也是写过关于vue和webpack构建项目的相关文章,大家有兴趣可以去看下 webpack+vue项目实战(一,搭建运行环境和相关配置)(这个系列一共有5篇文章,这是第一篇,其它几篇文章链接就不贴了)。但是关于vue入门基础的文章,我还没有写过,那么今天就写vue入门的三个小实例,这三个小实例是我刚接触vue的时候的练手作品,难度从很简单到简单,都是入门级的。希望能帮到大家更好的学习和了解vue,也是让自己能够复习一下vue。如果发现文章写得有什么不好,写错了,或者有什么建议!欢迎大家指点迷津!

    1.本篇文章使用的vue版本是 2.4.2,大家要注意版本问题
    2.现在我也是假设您有基础的html,css,javascript的知识,也已经看过了 官网的基本介绍,对vue有了一个大概的认识了,了解了常用的vue指令(v-model,v-show,v-if,v-for,v-on,v-bind等)!如果刚接触前端的话,你看着文章可能会蒙圈,建议先学习基础,掌握了基础知识再来看!
    3.下面的实例,建议大家边看文章边动手做!这样思路会非常清晰,不易混乱!也不会觉得文章长!如果只看文章,你可能未必会看完,因为文章我讲得比较细,比较长!

    2.什么是vue

    vue是现在很火的一个前端MVVM框架,它以数据驱动和组件化的思想构建,与angular和react并称前端三大框架。相比angular和react,vue更加轻巧、高性能、也很容易上手。大家也可以移步,看一下vue的介绍和核心功能 官网介绍。简单粗暴的理解就是:用vue开发的时候,就是操作数据,然后vue就会处理,以数据驱动去改变DOM(不知道有没有理解错,理解错了指点下)。
    下面就是一个最简单的说明例子

    代码如下

    html

    <div id="app"><p>{{ message }}</p><input v-model="message"></div>

    js

    new Vue({
      el: '#app',
      data: {
        message: 'Hello Vue!'
      }
    })

    栗子

    相信也不难理解,就是 input绑定了 message这个值,然后在 input修改的时候, message就改了,由于双向绑定,同时页面的html( {{ message }})进行了修改!
    好,下面进入例子学习!

    3.选项卡

    运行效果

    clipboard.png

    原理分析和实现

    这个很简单,无非就是一个点击切换显示而已。但是大家也要实现。如果这个看明白了,再看下面两个!这个实例应该只是一个热身和熟悉的作用!

    这个的步骤只有一步,原理也没什么。我直接在代码打注释,看了注释,大家就明白了!

    完整代码

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

    <meta charset="UTF-8"><title>Title</title>

    </head>
    <style>

    body{
        font-family:"Microsoft YaHei";
    }
    #tab{
        width: 600px;
        margin: 0 auto;
    }
    .tab-tit{
        font-size: 0;
        width: 600px;
    }
    .tab-tit a{
        display: inline-block;
        height: 40px;
        line-height: 40px;
        font-size: 16px;
        width: 25%;
        text-align: center;
        background: #ccc;
        color: #333;
        text-decoration: none;
    }
    .tab-tit .cur{
        background: #09f;
        color: #fff;
    }
    .tab-con div{
        border: 1px solid #ccc;
        height: 400px;
        padding-top: 20px;
    }

    </style>
    <body>
    <div id="tab">

    <div class="tab-tit"><!--点击设置curId的值  如果curId等于0,第一个a添加cur类名,如果curId等于1,第二个a添加cur类名,以此类推。添加了cur类名,a就会改变样式 @click,:class ,v-show这三个是vue常用的指令或添加事件的方式--><a href="javascript:;" @click="curId=0" :class="{'cur':curId===0}">html</a><a href="javascript:;" @click="curId=1" :class="{'cur':curId===1}">css</a><a href="javascript:;" @click="curId=2" :class="{'cur':curId===2}">javascript</a><a href="javascript:;" @click="curId=3" :class="{'cur':curId===3}">vue</a></div><div class="tab-con"><!--根据curId的值显示div,如果curId等于0,第一个div显示,其它三个div不显示。如果curId等于1,第二个div显示,其它三个div不显示。以此类推--><div v-show="curId===0">
            html<br/></div><div v-show="curId===1">
            css</div><div v-show="curId===2">
            javascript</div><div v-show="curId===3">
            vue</div></div>

    </div>
    </body>
    <script src="vue.min.js"></script>
    <script>

    new Vue({
        el: '#tab',
        data: {
            curId: 0
        },
        computed: {},
        methods: {},
        mounted: function () {
        }
    })

    </script>
    </html>

    4.统计总价

    运行效果

    clipboard.png

    原理分析和实现

    首先,还是先把布局写好,和引入vue,准备vue实例,这个不多说,代码如下

    <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Title</title><style>
            .fl{
                float: left;
            }
            .fr{
                float: right;
            }
           blockquote, body, dd, div, dl, dt, fieldset, form, h1, h2, h3, h4, h5, h6, img, input, li, ol, p, table, td, textarea, th, ul {
                margin: 0;
                padding: 0;
            }
           .clearfix{
              zoom: 1;
           }
            .clearfix:after {
                clear: both;
            }
            .clearfix:after {
                content: '.';
                display: block;
                overflow: hidden;
                visibility: hidden;
                font-size: 0;
                line-height: 0;
                width: 0;
                height: 0;
            }
            a{
                text-decoration: none;
                color: #333;
            }
            img{vertical-align: middle;}
            .page-shopping-cart {
                width: 1200px;
                margin: 50px auto;
                font-size: 14px;
                border: 1px solid #e3e3e3;
                border-top: 2px solid #317ee7; }
            .page-shopping-cart .cart-title {
                color: #317ee7;
                font-size: 16px;
                text-align: left;
                padding-left: 20px;
                line-height: 68px; }
            .page-shopping-cart .red-text {
                color: #e94826; }
            .page-shopping-cart .check-span {
                display: block;
                width: 24px;
                height: 20px;
                background: url("shopping_cart.png") no-repeat 0 0; }
            .page-shopping-cart .check-span.check-true {
                background: url("shopping_cart.png") no-repeat 0 -22px; }
            .page-shopping-cart .td-check {
                width: 70px; }
            .page-shopping-cart .td-product {
                width: 460px; }
            .page-shopping-cart .td-num, .page-shopping-cart .td-price, .page-shopping-cart .td-total {
                width: 160px; }
            .page-shopping-cart .td-do {
                width: 150px; }
            .page-shopping-cart .cart-product-title {
                text-align: center;
                height: 38px;
                line-height: 38px;
                padding: 0 20px;
                background: #f7f7f7;
                border-top: 1px solid #e3e3e3;
                border-bottom: 1px solid #e3e3e3; }
            .page-shopping-cart .cart-product-title .td-product {
                text-align: center;
                font-size: 14px; }
            .page-shopping-cart .cart-product-title .td-check {
                text-align: left; }
            .page-shopping-cart .cart-product-title .td-check .check-span {
                margin: 9px 6px 0 0; }
            .page-shopping-cart .cart-product {
                padding: 0 20px;
                text-align: center; }
            .page-shopping-cart .cart-product table {
                width: 100%;
                text-align: center;
                font-size: 14px; }
            .page-shopping-cart .cart-product table td {
                padding: 20px 0; }
            .page-shopping-cart .cart-product table tr {
                border-bottom: 1px dashed #e3e3e3; }
            .page-shopping-cart .cart-product table tr:last-child {
                border-bottom: none; }
            .page-shopping-cart .cart-product table .product-num {
                border: 1px solid #e3e3e3;
                display: inline-block;
                text-align: center; }
            .page-shopping-cart .cart-product table .product-num .num-do {
                width: 24px;
                height: 28px;
                display: block;
                background: #f7f7f7; }
            .page-shopping-cart .cart-product table .product-num .num-reduce span {
                background: url("shopping_cart.png") no-repeat -40px -22px;
                display: block;
                width: 6px;
                height: 2px;
                margin: 13px auto 0 auto; }
            .page-shopping-cart .cart-product table .product-num .num-add span {
                background: url("shopping_cart.png") no-repeat -60px -22px;
                display: block;
                width: 8px;
                height: 8px;
                margin: 10px auto 0 auto; }
            .page-shopping-cart .cart-product table .product-num .num-input {
                width: 42px;
                height: 28px;
                line-height: 28px;
                border: none;
                text-align: center; }
            .page-shopping-cart .cart-product table .td-product {
                text-align: left;
                font-size: 12px;
                line-height: 20px; }
            .page-shopping-cart .cart-product table .td-product img {
                border: 1px solid #e3e3e3;
                margin-right: 10px; }
            .page-shopping-cart .cart-product table .td-product .product-info {
                display: inline-block;
                vertical-align: middle; }
            .page-shopping-cart .cart-product table .td-do {
                font-size: 12px; }
            .page-shopping-cart .cart-product-info {
                height: 50px;
                line-height: 50px;
                background: #f7f7f7;
                padding-left: 20px; }
            .page-shopping-cart .cart-product-info .delect-product {
                color: #666; }
            .page-shopping-cart .cart-product-info .delect-product span {
                display: inline-block;
                vertical-align: top;
                margin: 18px 8px 0 0;
                width: 13px;
                height: 15px;
                background: url("shopping_cart.png") no-repeat -60px 0; }
            .page-shopping-cart .cart-product-info .product-total {
                font-size: 14px;
                color: #e94826; }
            .page-shopping-cart .cart-product-info .product-total span {
                font-size: 20px; }
            .page-shopping-cart .cart-product-info .check-num {
                color: #333; }
            .page-shopping-cart .cart-product-info .check-num span {
                color: #e94826; }
            .page-shopping-cart .cart-product-info .keep-shopping {
                color: #666;
                margin-left: 40px; }
            .page-shopping-cart .cart-product-info .keep-shopping span {
                display: inline-block;
                vertical-align: top;
                margin: 18px 8px 0 0;
                width: 15px;
                height: 15px;
                background: url("shopping_cart.png") no-repeat -40px 0; }
            .page-shopping-cart .cart-product-info .btn-buy {
                height: 50px;
                color: #fff;
                font-size: 20px;
                display: block;
                width: 110px;
                background: #ff7700;
                text-align: center;
                margin-left: 30px; }
            .page-shopping-cart .cart-worder {
                padding: 20px; }
            .page-shopping-cart .cart-worder .choose-worder {
                color: #fff;
                display: block;
                background: #39e;
                width: 140px;
                height: 40px;
                line-height: 40px;
                border-radius: 4px;
                text-align: center;
                margin-right: 20px; }
            .page-shopping-cart .cart-worder .choose-worder span {
                display: inline-block;
                vertical-align: top;
                margin: 9px 10px 0 0;
                width: 22px;
                height: 22px;
                background: url("shopping_cart.png") no-repeat -92px 0; }
            .page-shopping-cart .cart-worder .worker-info {
                color: #666; }
            .page-shopping-cart .cart-worder .worker-info img {
                border-radius: 100%;
                margin-right: 10px; }
            .page-shopping-cart .cart-worder .worker-info span {
                color: #000; }
    
            .choose-worker-box {
                width: 620px;
                background: #fff; }
            .choose-worker-box .box-title {
                height: 40px;
                line-height: 40px;
                background: #F7F7F7;
                text-align: center;
                position: relative;
                font-size: 14px; }
            .choose-worker-box .box-title a {
                display: block;
                position: absolute;
                top: 15px;
                right: 16px;
                width: 10px;
                height: 10px;
                background: url("shopping_cart.png") no-repeat -80px 0; }
            .choose-worker-box .box-title a:hover {
                background: url("shopping_cart.png") no-repeat -80px -22px; }
            .choose-worker-box .worker-list {
                padding-top: 30px;
                height: 134px;
                overflow-y: auto; }
            .choose-worker-box .worker-list li {
                float: left;
                width: 25%;
                text-align: center;
                margin-bottom: 30px; }
            .choose-worker-box .worker-list li p {
                margin-top: 8px; }
            .choose-worker-box .worker-list li.cur a {
                color: #f70; }
            .choose-worker-box .worker-list li.cur a img {
                border: 1px solid #f70; }
            .choose-worker-box .worker-list li a:hover {
                color: #f70; }
            .choose-worker-box .worker-list li a:hover img {
                border: 1px solid #f70; }
            .choose-worker-box .worker-list li img {
                border: 1px solid #fff;
                border-radius: 100%; }</style></head><body><div class="page-shopping-cart" id="shopping-cart"><h4 class="cart-title">购物清单</h4><div class="cart-product-title clearfix"><div class="td-check fl"><span class="check-span fl check-all"></span>全选</div><div class="td-product fl">商品</div><div class="td-num fl">数量</div><div class="td-price fl">单价(元)</div><div class="td-total fl">金额(元)</div><div class="td-do fl">操作</div></div><div class="cart-product clearfix"><table><tbody><tr><td class="td-check"><span class="check-span"></span></td><td class="td-product"><img src="testimg.jpg" width="98" height="98"><div class="product-info"><h6>【斯文】甘油&nbsp;|&nbsp;丙三醇</h6><p>品牌:韩国skc&nbsp;&nbsp;产地:韩国</p><p>规格/纯度:99.7%&nbsp;&nbsp;起定量:215千克</p><p>配送仓储:上海仓海仓储</p></div><div class="clearfix"></div></td><td class="td-num"><div class="product-num"><a href="javascript:;" class="num-reduce num-do fl"><span></span></a><input type="text" class="num-input" value="3"><a href="javascript:;" class="num-add num-do fr"><span></span></a></div></td><td class="td-price"><p class="red-text">¥<span class="price-text">800</span>.00</p></td><td class="td-total"><p class="red-text">¥<span class="total-text">800</span>.00</p></td><td class="td-do"><a href="javascript:;" class="product-delect">删除</a></td></tr><tr><td class="td-check"><span class="check-span check-true"></span></td><td class="td-product"><img src="testimg.jpg" width="98" height="98"><div class="product-info"><h6>【斯文】甘油&nbsp;|&nbsp;丙三醇</h6><p>品牌:韩国skc&nbsp;&nbsp;产地:韩国</p><p>规格/纯度:99.7%&nbsp;&nbsp;起定量:215千克</p><p>配送仓储:上海仓海仓储</p></div><div class="clearfix"></div></td><td class="td-num"><div class="product-num"><a href="javascript:;" class="num-reduce num-do fl"><span></span></a><input type="text" class="num-input" value="1"><a href="javascript:;" class="num-add num-do fr"><span></span></a></div></td><td class="td-price"><p class="red-text">¥<span class="price-text">800</span>.00</p></td><td class="td-total"><p class="red-text">¥<span class="total-text">800</span>.00</p></td><td class="td-do"><a href="javascript:;" class="product-delect">删除</a></td></tr></tbody></table></div><div class="cart-product-info"><a class="delect-product" href="javascript:;"><span></span>删除所选商品</a><a class="keep-shopping" href="#"><span></span>继续购物</a><a class="btn-buy fr" href="javascript:;">去结算</a><p class="fr product-total">¥<span>1600</span></p><p class="fr check-num"><span>2</span>件商品总计(不含运费):</p></div><div class="cart-worder clearfix"><a href="javascript:;" class="choose-worder fl"><span></span>绑定跟单员</a><div class="worker-info fl"></div></div></div></body><script src="vue.min.js"></script><script>
        new Vue({
            el:'#shopping-cart',
            data:{
    
            },
            computed: {},
            methods:{
                
            }
        })
    </script></html>

    然后准备下列表数据,根据下面表格的箭头

    clipboard.png

    所以大家就知道吗,下面的数据大概是涨这样

    productList:[
        {'pro_name': '【斯文】甘油 | 丙三醇',//产品名称
            'pro_brand': 'skc',//品牌名称
            'pro_place': '韩国',//产地
            'pro_purity': '99.7%',//规格
            'pro_min': "215千克",//最小起订量
            'pro_depot': '上海仓海仓储',//所在仓库
            'pro_num': 3,//数量
            'pro_img': '../../images/ucenter/testimg.jpg',//图片链接
            'pro_price': 800//单价
        }
    ]

    准备了这么多,大家可能想到,还缺少一个,就是记录产品是否有选中,但是这个字段,虽然可以在上面那里加,但是意义不大,比如在平常项目那里!后台的数据不会这样返回,数据库也不会有这个字段,这个字段应该是自己添加的。代码如下

    new Vue({
        el:'#shopping-cart',
        data:{
            productList:[
                {'pro_name': '【斯文】甘油 | 丙三醇',//产品名称
                    'pro_brand': 'skc',//品牌名称
                    'pro_place': '韩国',//产地
                    'pro_purity': '99.7%',//规格
                    'pro_min': "215千克",//最小起订量
                    'pro_depot': '上海仓海仓储',//所在仓库
                    'pro_num': 3,//数量
                    'pro_img': '../../images/ucenter/testimg.jpg',//图片链接
                    'pro_price': 800//单价
                }
            ]
        },
        computed: {},
        methods:{
    
        },
        mounted:function () {
            //为productList添加select(是否选中)字段,初始值为true
            this.productList.map(function(item){item.select=true;console.log(item)})
        }
    })
              

    步骤1

    为了着重表示我修改了什么地方,代码我现在只贴出修改的部分,大家对着上面的布局,就很容易知道我改的是什么地方了!下面也是这样操作!

    点击增加和减少按钮(箭头指向地方),所属列的金额改变(红框地方)
    clipboard.png

    执行步骤1之前,要先把列表的数据给铺出来。利用v-for指令。代码如下

    <tr v-for="item in productList"><td class="td-check"><span class="check-span"></span></td><td class="td-product"><img :src="item.pro_img" width="98" height="98"><div class="product-info"><h6>{{item.pro_name}}</h6><p>品牌:{{item.pro_brand}}&nbsp;&nbsp;产地:{{item.pro_place}}</p><p>规格/纯度:{{item.pro_purity}}&nbsp;&nbsp;起定量:{{item.pro_min}}</p><p>配送仓储:{{item.pro_depot}}</p></div><div class="clearfix"></div></td><td class="td-num"><div class="product-num"><a href="javascript:;" class="num-reduce num-do fl" @click="item.pro_num--"><span></span></a><input type="text" class="num-input" v-model="item.pro_num"><a href="javascript:;" class="num-add num-do fr" @click="item.pro_num++"><span></span></a></div></td><td class="td-price"><p class="red-text">¥<span class="price-text">{{item.pro_price.toFixed(2)}}</span></p></td><td class="td-total"><p class="red-text">¥<span class="total-text">{{item.pro_price*item.pro_num}}</span>.00</p></td><td class="td-do"><a href="javascript:;" class="product-delect">删除</a></td></tr>

    这样,列表的数据就有了!

    clipboard.png

    也可以发现, clipboard.png这两个按钮的功能已经实现了,后面的金额也会发生变化!是不是感到很惊喜!其实这里没什么特别的,就是因为输入框利用v-model绑定了数量( pro_num),然后两个按钮分别添加了事件 @click="item.pro_num--"和@ click="item.pro_num++"。比如刚开始pro_num是3,点击 clipboard.pngpro_num就变成2,点击 clipboard.png
    pro_num就变成4,然后后面的金额会改改,是因为 {{item.pro_price*item.pro_num}}。只要pro_price或者pro_num的值改变了,整一块也会改变,视图就会刷新,我们就能看到变化(这些事情是vue做的,这就是MVVM的魅力,数据驱动视图改变)。

    步骤2

    点击所属列选择按钮(箭头指向地方),总计的金额(红框地方)和已选产品的列数(蓝框地方)和全选(黄框地方)会改变(如果已经全选了,全选按钮自动变成全选,如果没有全选,全选按钮,自动取消全选)!

    clipboard.png

    首先,选择与取消选择,在这里只有两个操作(其实只有一个:改变这条记录的 select字段)。

    clipboard.png

    然后改变 clipboard.png,如果这条记录 selectfalse,就显示 clipboard.png,否则就显示 clipboard.png
    代码如下

    <td class="td-check"><span class="check-span" @click="item.select=!item.select" :class="{'check-true':item.select}"></span></td>

    其实就是等于添加了 @click="item.select=!item.select" :class="{'check-true':item.select}"这里。点击这个,这条数据的 select字段就取反(true->false或者false->true)。然后 :class="{'check-true':item.select}",就会根据这条数据的 select字段进行判断,是否添加 check-true类名,如果 select字段为true,就添加类名,显示 clipboard.png。否则不添加类名,显示
    clipboard.png

    然后, clipboard.png全选按钮,是否变成 clipboard.png。这里用一个computed(计算属性)就好。代码如下

    html

    <div class="td-check fl"><span class="check-span fl check-all" :class="{'check-true':isSelectAll}"></span>全选</div>

    js

    computed: {
        isSelectAll:function(){
            //如果productList中每一条数据的select都为true,返回true,否则返回false;
            return this.productList.every(function (val) { return val.select});
        }
    }

    代码我解释下,就是计算属性中,定义的isSelectAll依赖productList。只要productList改变,isSelectAll的返回值就会改变,然后 :class="{'check-true':isSelectAll}"根绝isSelectAll返回值是否添加 'check-true'类名,显示对应的样式!
    最后, clipboard.png,这里的多少件产品和总价,也是使用计算属性,有了上一步的基础,给出代码,大家一看就明白了!
    html

    <p class="fr product-total">¥<span>{{getTotal.totalPrice}}</span></p><p class="fr check-num"><span>{{getTotal.totalNum}}</span>件商品总计(不含运费):</p>

    js

    computed: {
        //检测是否全选
        isSelectAll:function(){
            //如果productList中每一条数据的select都为true,返回true,否则返回false;
            return this.productList.every(function (val) { return val.select});
        },
        //获取总价和产品总件数
        getTotal:function(){
            //获取productList中select为true的数据。
            var _proList=this.productList.filter(function (val) { return val.select}),totalPrice=0;
            for(var i=0,len=_proList.length;i<len;i++){
                //总价累加
                totalPrice+=_proList[i].pro_num*_proList[i].pro_price;
            }
            //选择产品的件数就是_proList.length,总价就是totalPrice
            return {totalNum:_proList.length,totalPrice:totalPrice}
        }
    },

    代码很简单,html根据getTotal返回值显示数据,getTotal依赖productList的数据,只要productList改变,返回值会改变,视图也会改变!

    clipboard.png

    步骤3

    点击全选按钮(箭头指向部分),会自动的对产品进行全选或者取消全选,下面的总计也会发生改变

    clipboard.png
    做到这一步,大家应该知道,全选或者取消全选,就是改变记录的 select。但是怎么知道现在的列表有没有全选呢?这个很贱,不需要在操作函数(全选与取消全选函数)里面遍历,大家应该还记得第二步的计算属性 isSelectAll(为true就是全选,否则不是全选),把这个传进操作函数就好,然后操作函数,根据参数,决定执行全选,还是取消全选操作。代码如下!
    html

    <div class="td-check fl"><span class="check-span fl check-all" :class="{'check-true':isSelectAll}" @click="selectProduct(isSelectAll)"></span>全选</div>

    js

     methods: {
        //全选与取消全选
        selectProduct:function(_isSelect){
            //遍历productList,全部取反
            for (var i = 0, len = this.productList.length; i < len; i++) {
                this.productList[i].select = !_isSelect;
            }
        }
    },

    clipboard.png

    步骤4

    点击删除产品,会删除已经选中的,全选按钮和下面的总计,都会变化!点击每条记录后面的删除,会删除当前的这条记录。全选按钮和下面的总计,也都会变化!

    clipboard.png

    首先,点击删除产品,删除已经选中。这个大家知道了怎么做了!就是遍历productList,如果哪条记录的select为true,就删除。
    然后,点击每条记录后面的删除,删除当前的这条记录。这个在html遍历productList的时候。顺便带上索引,然后把索引当成参数,传进操作函数,然后根据索引参数,删除productList的哪一条记录。即可实现!代码如下!
    html

    <!--遍历的时候带上索引--><tr v-for="(item,index) in productList"><td class="td-check"><span class="check-span" @click="item.select=!item.select" :class="{'check-true':item.select}"></span></td><td class="td-product"><img :src="item.pro_img" width="98" height="98"><div class="product-info"><h6>{{item.pro_name}}</h6><p>品牌:{{item.pro_brand}}&nbsp;&nbsp;产地:{{item.pro_place}}</p><p>规格/纯度:{{item.pro_purity}}&nbsp;&nbsp;起定量:{{item.pro_min}}</p><p>配送仓储:{{item.pro_depot}}</p></div><div class="clearfix"></div></td><td class="td-num"><div class="product-num"><a href="javascript:;" class="num-reduce num-do fl" @click="item.pro_num--"><span></span></a><input type="text" class="num-input" v-model="item.pro_num"><a href="javascript:;" class="num-add num-do fr" @click="item.pro_num++"><span></span></a></div></td><td class="td-price"><p class="red-text">¥<span class="price-text">{{item.pro_price.toFixed(2)}}</span></p></td><td class="td-total"><p class="red-text">¥<span class="total-text">{{item.pro_price*item.pro_num}}</span>.00</p></td><td class="td-do"><a href="javascript:;" class="product-delect" @click="deleteOneProduct(index)">删除</a></td></tr>
    ...<a class="delect-product" href="javascript:;" @click="deleteProduct"><span></span>删除所选商品</a>

    js

    //删除已经选中(select=true)的产品
    deleteProduct:function () {
        this.productList=this.productList.filter(function (item) {return !item.select})
    },
    //删除单条产品
    deleteOneProduct:function (index) {
        //根据索引删除productList的记录
        this.productList.splice(index,1);
    },

    完整代码

    样式图片

    clipboard.png

    <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Title</title><style>
                .fl {
                    float: left;
                } 
                .fr {
                    float: right;
                }
                blockquote, body, dd, div, dl, dt, fieldset, form, h1, h2, h3, h4, h5, h6, img, input, li, ol, p, table, td, textarea, th, ul {
                    margin: 0;
                    padding: 0;
                }
                .clearfix {
                    zoom: 1;
                }
                .clearfix:after {
                    clear: both;
                }
                .clearfix:after {
                    content: '.';
                    display: block;
                    overflow: hidden;
                    visibility: hidden;
                    font-size: 0;
                    line-height: 0;
                    width: 0;
                    height: 0;
                }
                a {
                    text-decoration: none;
                    color: #333;
                }
                img {
                    vertical-align: middle;
                }
                .page-shopping-cart {
                    width: 1200px;
                    margin: 50px auto;
                    font-size: 14px;
                    border: 1px solid #e3e3e3;
                    border-top: 2px solid #317ee7;
                }
                .page-shopping-cart .cart-title {
                    color: #317ee7;
                    font-size: 16px;
                    text-align: left;
                    padding-left: 20px;
                    line-height: 68px;
                }
                .page-shopping-cart .red-text {
                    color: #e94826;
                }
                .page-shopping-cart .check-span {
                    display: block;
                    width: 24px;
                    height: 20px;
                    background: url("shopping_cart.png") no-repeat 0 0;
                }
                .page-shopping-cart .check-span.check-true {
                    background: url("shopping_cart.png") no-repeat 0 -22px;
                }
                .page-shopping-cart .td-check {
                    width: 70px;
                }
                .page-shopping-cart .td-product {
                    width: 460px;
                }
                .page-shopping-cart .td-num, .page-shopping-cart .td-price, .page-shopping-cart .td-total {
                    width: 160px;
                }
                .page-shopping-cart .td-do {
                    width: 150px;
                }
                .page-shopping-cart .cart-product-title {
                    text-align: center;
                    height: 38px;
                    line-height: 38px;
                    padding: 0 20px;
                    background: #f7f7f7;
                    border-top: 1px solid #e3e3e3;
                    border-bottom: 1px solid #e3e3e3;
                }
                .page-shopping-cart .cart-product-title .td-product {
                    text-align: center;
                    font-size: 14px;
                }
                .page-shopping-cart .cart-product-title .td-check {
                    text-align: left;
                }
                .page-shopping-cart .cart-product-title .td-check .check-span {
                    margin: 9px 6px 0 0;
                }
                .page-shopping-cart .cart-product {
                    padding: 0 20px;
                    text-align: center;
                }
                .page-shopping-cart .cart-product table {
                    width: 100%;
                    text-align: center;
                    font-size: 14px;
                }
                .page-shopping-cart .cart-product table td {
                    padding: 20px 0;
                }
                .page-shopping-cart .cart-product table tr {
                    border-bottom: 1px dashed #e3e3e3;
                }
                .page-shopping-cart .cart-product table tr:last-child {
                    border-bottom: none;
                }
                .page-shopping-cart .cart-product table .product-num {
                    border: 1px solid #e3e3e3;
                    display: inline-block;
                    text-align: center;
                }
                .page-shopping-cart .cart-product table .product-num .num-do {
                    width: 24px;
                    height: 28px;
                    display: block;
                    background: #f7f7f7;
                }
                .page-shopping-cart .cart-product table .product-num .num-reduce span {
                    background: url("shopping_cart.png") no-repeat -40px -22px;
                    display: block;
                    width: 6px;
                    height: 2px;
                    margin: 13px auto 0 auto;
                }
                .page-shopping-cart .cart-product table .product-num .num-add span {
                    background: url("shopping_cart.png") no-repeat -60px -22px;
                    display: block;
                    width: 8px;
                    height: 8px;
                    margin: 10px auto 0 auto;
                }
                .page-shopping-cart .cart-product table .product-num .num-input {
                    width: 42px;
                    height: 28px;
                    line-height: 28px;
                    border: none;
                    text-align: center;
                }
                .page-shopping-cart .cart-product table .td-product {
                    text-align: left;
                    font-size: 12px;
                    line-height: 20px;
                }
                .page-shopping-cart .cart-product table .td-product img {
                    border: 1px solid #e3e3e3;
                    margin-right: 10px;
                }
                .page-shopping-cart .cart-product table .td-product .product-info {
                    display: inline-block;
                    vertical-align: middle;
                }
                .page-shopping-cart .cart-product table .td-do {
                    font-size: 12px;
                }
                .page-shopping-cart .cart-product-info {
                    height: 50px;
                    line-height: 50px;
                    background: #f7f7f7;
                    padding-left: 20px;
                }
                .page-shopping-cart .cart-product-info .delect-product {
                    color: #666;
                }
                .page-shopping-cart .cart-product-info .delect-product span {
                    display: inline-block;
                    vertical-align: top;
                    margin: 18px 8px 0 0;
                    width: 13px;
                    height: 15px;
                    background: url("shopping_cart.png") no-repeat -60px 0;
                }
                .page-shopping-cart .cart-product-info .product-total {
                    font-size: 14px;
                    color: #e94826;
                }
                .page-shopping-cart .cart-product-info .product-total span {
                    font-size: 20px;
                }
                .page-shopping-cart .cart-product-info .check-num {
                    color: #333;
                }
                .page-shopping-cart .cart-product-info .check-num span {
                    color: #e94826;
                }
                .page-shopping-cart .cart-product-info .keep-shopping {
                    color: #666;
                    margin-left: 40px;
                }
                .page-shopping-cart .cart-product-info .keep-shopping span {
                    display: inline-block;
                    vertical-align: top;
                    margin: 18px 8px 0 0;
                    width: 15px;
                    height: 15px;
                    background: url("shopping_cart.png") no-repeat -40px 0;
                }
                .page-shopping-cart .cart-product-info .btn-buy {
                    height: 50px;
                    color: #fff;
                    font-size: 20px;
                    display: block;
                    width: 110px;
                    background: #ff7700;
                    text-align: center;
                    margin-left: 30px;
                }
                .page-shopping-cart .cart-worder {
                    padding: 20px;
                }
                .page-shopping-cart .cart-worder .choose-worder {
                    color: #fff;
                    display: block;
                    background: #39e;
                    width: 140px;
                    height: 40px;
                    line-height: 40px;
                    border-radius: 4px;
                    text-align: center;
                    margin-right: 20px;
                }
                .page-shopping-cart .cart-worder .choose-worder span {
                    display: inline-block;
                    vertical-align: top;
                    margin: 9px 10px 0 0;
                    width: 22px;
                    height: 22px;
                    background: url("shopping_cart.png") no-repeat -92px 0;
                }
                .page-shopping-cart .cart-worder .worker-info {
                    color: #666;
                }
                .page-shopping-cart .cart-worder .worker-info img {
                    border-radius: 100%;
                    margin-right: 10px;
                }
                .page-shopping-cart .cart-worder .worker-info span {
                    color: #000;
                }
                .choose-worker-box {
                    width: 620px;
                    background: #fff;
                }
                .choose-worker-box .box-title {
                    height: 40px;
                    line-height: 40px;
                    background: #F7F7F7;
                    text-align: center;
                    position: relative;
                    font-size: 14px;
                }
                .choose-worker-box .box-title a {
                    display: block;
                    position: absolute;
                    top: 15px;
                    right: 16px;
                    width: 10px;
                    height: 10px;
                    background: url("shopping_cart.png") no-repeat -80px 0;
                }
                .choose-worker-box .box-title a:hover {
                    background: url("shopping_cart.png") no-repeat -80px -22px;
                }
                .choose-worker-box .worker-list {
                    padding-top: 30px;
                    height: 134px;
                    overflow-y: auto;
                }
                .choose-worker-box .worker-list li {
                    float: left;
                    width: 25%;
                    text-align: center;
                    margin-bottom: 30px;
                }
                .choose-worker-box .worker-list li p {
                    margin-top: 8px;
                }
                .choose-worker-box .worker-list li.cur a {
                    color: #f70;
                }
                .choose-worker-box .worker-list li.cur a img {
                    border: 1px solid #f70;
                }
                .choose-worker-box .worker-list li a:hover {
                    color: #f70;
                }
                .choose-worker-box .worker-list li a:hover img {
                    border: 1px solid #f70;
                }
                .choose-worker-box .worker-list li img {
                    border: 1px solid #fff;
                    border-radius: 100%;
                }</style></head><body><div class="page-shopping-cart" id="shopping-cart"><h4 class="cart-title">购物清单</h4><div class="cart-product-title clearfix"><div class="td-check fl"><span class="check-span fl check-all" :class="{'check-true':isSelectAll}" @click="selectProduct(isSelectAll)"></span>全选</div><div class="td-product fl">商品</div><div class="td-num fl">数量</div><div class="td-price fl">单价(元)</div><div class="td-total fl">金额(元)</div><div class="td-do fl">操作</div></div><div class="cart-product clearfix"><table><tbody><!--遍历的时候带上索引--><tr v-for="(item,index) in productList"><td class="td-check"><span class="check-span" @click="item.select=!item.select" :class="{'check-true':item.select}"></span></td><td class="td-product"><img :src="item.pro_img" width="98" height="98"><div class="product-info"><h6>{{item.pro_name}}</h6><p>品牌:{{item.pro_brand}}&nbsp;&nbsp;产地:{{item.pro_place}}</p><p>规格/纯度:{{item.pro_purity}}&nbsp;&nbsp;起定量:{{item.pro_min}}</p><p>配送仓储:{{item.pro_depot}}</p></div><div class="clearfix"></div></td><td class="td-num"><div class="product-num"><a href="javascript:;" class="num-reduce num-do fl" @click="item.pro_num--"><span></span></a><input type="text" class="num-input" v-model="item.pro_num"><a href="javascript:;" class="num-add num-do fr" @click="item.pro_num++"><span></span></a></div></td><td class="td-price"><p class="red-text">¥<span class="price-text">{{item.pro_price.toFixed(2)}}</span></p></td><td class="td-total"><p class="red-text">¥<span class="total-text">{{item.pro_price*item.pro_num}}</span>.00</p></td><td class="td-do"><a href="javascript:;" class="product-delect" @click="deleteOneProduct(index)">删除</a></td></tr></tbody></table></div><div class="cart-product-info"><a class="delect-product" href="javascript:;" @click="deleteProduct"><span></span>删除所选商品</a><a class="keep-shopping" href="#"><span></span>继续购物</a><a class="btn-buy fr" href="javascript:;">去结算</a><p class="fr product-total">¥<span>{{getTotal.totalPrice}}</span></p><p class="fr check-num"><span>{{getTotal.totalNum}}</span>件商品总计(不含运费):</p></div></div></body><script src="vue.min.js"></script><script>
            new Vue({
                el: '#shopping-cart',
                data: {
                    productList: [
                        {'pro_name': '【斯文】甘油 | 丙三醇',//产品名称
                            'pro_brand': 'skc',//品牌名称
                            'pro_place': '韩国',//产地
                            'pro_purity': '99.7%',//规格
                            'pro_min': "215千克",//最小起订量
                            'pro_depot': '上海仓海仓储',//所在仓库
                            'pro_num': 3,//数量
                            'pro_img': '../../images/ucenter/testimg.jpg',//图片链接
                            'pro_price': 800//单价
                        },
                        {
                            'pro_name': '【斯文】甘油 | 丙三醇',//产品名称
                            'pro_brand': 'skc',//品牌名称
                            'pro_place': '韩国',//产地
                            'pro_purity': '99.7%',//规格
                            'pro_min': "215千克",//最小起订量
                            'pro_depot': '上海仓海仓储',//所在仓库
                            'pro_num': 3,//数量
                            'pro_img': '../../images/ucenter/testimg.jpg',//图片链接
                            'pro_price': 800//单价
                        },
                        {
                            'pro_name': '【斯文】甘油 | 丙三醇',//产品名称
                            'pro_brand': 'skc',//品牌名称
                            'pro_place': '韩国',//产地
                            'pro_purity': '99.7%',//规格
                            'pro_min': "215千克",//最小起订量
                            'pro_depot': '上海仓海仓储',//所在仓库
                            'pro_num': 3,//数量
                            'pro_img': '../../images/ucenter/testimg.jpg',//图片链接
                            'pro_price': 800//单价
                        }
                    ]
                },
                computed: {
                    //检测是否全选
                    isSelectAll:function(){
                        //如果productList中每一条数据的select都为true,返回true,否则返回false;
                        return this.productList.every(function (val) { return val.select});
                    },
                    //获取总价和产品总件数
                    getTotal:function(){
                        //获取productList中select为true的数据。
                        var _proList=this.productList.filter(function (val) { return val.select}),totalPrice=0;
                        for(var i=0,len=_proList.length;i<len;i++){
                            //总价累加
                            totalPrice+=_proList[i].pro_num*_proList[i].pro_price;
                        }
                        //选择产品的件数就是_proList.length,总价就是totalPrice
                        return {totalNum:_proList.length,totalPrice:totalPrice}
                    }
                },
                methods: {
                    //全选与取消全选
                    selectProduct:function(_isSelect){
                        //遍历productList,全部取反
                        for (var i = 0, len = this.productList.length; i < len; i++) {
                            this.productList[i].select = !_isSelect;
                        }
                    },
                    //删除已经选中(select=true)的产品
                    deleteProduct:function () {
                        this.productList=this.productList.filter(function (item) {return !item.select})
                    },
                    //删除单条产品
                    deleteOneProduct:function (index) {
                        //根据索引删除productList的记录
                        this.productList.splice(index,1);
                    },
                },
                mounted: function () {
                    var _this=this;
                    //为productList添加select(是否选中)字段,初始值为true
                    this.productList.map(function (item) {
                        _this.$set(item, 'select', true);
                    })
                }
            })</script></html>  

    5.todoList

    运行效果

    clipboard.png

    原理分析和实现

    首先,还是先把布局写好,和引入vue,准备vue实例,这个不多说,代码如下

    <!DOCTYPE html><html><head><meta charset="UTF-8"><title></title><style>
                body{font-family: "微软雅黑";font-size: 14px;}
                input{font-size: 14px;}
                body,ul,div,html{padding: 0;margin: 0;}
                .hidden{display: none;}
                .main{width: 800px;margin: 0 auto;}
                li{list-style-type: none;line-height: 40px;position: relative;border: 1px solid transparent;padding: 0 20px;}
                li .type-span{display: block;width: 10px;height: 10px;background: #ccc;margin: 14px 10px 0 0 ;float: left;}
                li .close{position: absolute;color: #f00;font-size: 20px;line-height: 40px;height: 40px;right: 20px;cursor: pointer;display: none;top: 0;}
                li:hover{border: 1px solid #09f;}
                li:hover .close{display: block;}
                li .text-keyword{height: 40px;padding-left: 10px;box-sizing: border-box;margin-left: 10px;width: 80%;display: none;}
                .text-keyword{box-sizing: border-box;width: 100%;height: 40px;padding-left: 10px;outline: none;}</style></head><body><div id="app" class="main"><h2>小目标列表</h2><div class="list"><h3>添加小目标</h3><input type="text" class="text-keyword" placeholder="输入小目标后,按回车确认"/><p>共有N个目标</p><p><input type="radio" name="chooseType" checked="true"/><label>所有目标</label><input type="radio" name="chooseType"/><label>已完成目标</label><input type="radio" name="chooseType"/><label>未完成目标</label></p></div><ul><li class="li1"><div><span class="type-span"></span><span>html5</span><span class="close">X</span></div></li><li class="li1"><div><span class="type-span"></span><span>css3</span><span class="close">X</span></div></li></ul></div></body><script src="vue2.4.2.js"></script><script type="text/javascript">
        new Vue({
            el: "#app",
            data: {
            },
            computed:{
            },
            methods:{
            }
        });</script></html>

    布局有了,相当于一个骨架就有了,下面实现功能,一个一个来

    步骤1

    输入并回车,多一条记录。下面的记录文字也会改变

    clipboard.png

    首先,大的输入框回车要添加纪录,那么输入框必须绑定一个值和一个添加纪录的方法。
    代码如下:
    然后,下面的记录也要改变,所以,下面的记录也要帮一个值,因为这个记录可能会有多个,这个值就是一个数组,也可以看到,记录除了名称,还有记录是否完成的状态,所以,绑定记录的这个值肯定是一个对象数组!代码如下
    最后,记录文字 clipboard.png要改变。这个只是一个当前记录的长度即可!

    为了着重表示我修改了什么地方,代码我现在只贴出修改的部分,大家对着上面的布局,就很容易知道我改的是什么地方了!下面也是这样操作!

    html代码

    <!--利用v-model把addText绑定到input--><input type="text" class="text-keyword" placeholder="输入小目标后,按回车确认" @keyup.13='addList' v-model="addText"/><p>共有{{prolist.length}}个目标</p><!--v-for遍历prolist--><li class="li1" v-for="list in prolist"><div><span class="type-span"></span><span>{{list.name}}</span><span class="close">X</span></div></li>

    js代码

    new Vue({
        el: "#app",
        data: {
            addText:'',
            //name-名称,status-完成状态
           prolist:[
                   {name:"HTML5",status:false},
                   {name:"CSS3",status:false},
                   {name:"vue",status:false},
                   {name:"react",status:false}
            ]
        },
        computed:{
        },
        methods:{
            addList(){
                //添加进来默认status=false,就是未完成状态
                this.prolist.push({
                    name:this.addText,
                    status:false
                });
                //添加后,清空addText
                this.addText="";
            }
        }
    });

    测试一下,没问题

    clipboard.png

    步骤2

    点击切换,下面记录会改变

    clipboard.png

    看到三个选项,也很简单,无非就是三个选择,一个是所有的目标,一个是所有已经完成的目标,一个是所有没完成的目标。
    首先.新建一个新的变量(newList),储存prolist。遍历的时候不再遍历prolist,而是遍历newList。改变也是改变newList。
    然后.选择所有目标的时候,显示全部prolist,把prolist赋值给newList。
    然后.选择所有已经完成目标的时候,只显示prolist中,status为true的目标,把prolist中,status为true的项赋值给newList,
    最后.选择所有未完成目标的时候,只显示status为false的目标,把prolist中,status为false的项赋值给newList。

    代码如下

    html

    <ul><li class="li1" v-for="list in newList"><div><span class="status-span"></span><span>{{list.name}}</span><span class="close" @click='delectList(index)'>X</span></div></li></ul>

    js

    new Vue({
        el: "#app",
        data: {
            addText:'',
            //name-名称,status-完成状态
           prolist:[
                   {name:"HTML5",status:false},
                   {name:"CSS3",status:false},
                   {name:"vue",status:false},
                   {name:"react",status:false}
            ],
            newList:[]
        },
        computed:{
            noend:function(){
                return this.prolist.filter(function(item){
                    return !item.status
                }).length;
            }
        },
        methods:{
            addList(){
                //添加进来默认status=false,就是未完成状态
                this.prolist.push({
                    name:this.addText,
                    status:false
                });
                //添加后,清空addText
                this.addText="";
            },
            chooseList(type){
                //type=1时,选择所有目标
                //type=2时,选择所有已完成目标
                //type=3时,选择所有未完成目标
                switch(type){
                    case 1:this.newList=this.prolist;break;
                    case 2:this.newList=this.prolist.filter(function(item){return item.status});break;
                    case 3:this.newList=this.prolist.filter(function(item){return !item.status});break;
                }
            },
            delectList(index){
                //根据索引,删除数组某一项
                this.prolist.splice(index,1);
                //更新newList  newList可能经过this.prolist.filter()赋值,这样的话,删除了prolist不会影响到newList  那么就要手动更新newList
                this.newList=this.prolist;
            },
        },
        mounted(){
            //初始化,把prolist赋值给newList。默认显示所有目标
            this.newList=this.prolist;
        }
    });
    

    运行结果

    clipboard.png

    步骤3

    红色关闭标识,点击会删除该记录。前面按钮点击会切换该记录完成状态,颜色也改变,记录文字也跟着改变

    clipboard.png

    首先点击红色关闭标识,点击会删除该记录。这个应该没什么问题,就是删除prolist的一条记录!
    然后前面按钮点击会切换该记录完成状态。这个也没什么,就是改变prolist的一条记录的status字段!
    最后记录文字的改变,就是记录prolist中status为false的有多少条,prolist中status为true的有多少条而已

    html代码

    <!--如果noend等于0,就是全部完成了就显示‘全部完成了’,如果没有就是显示已完成多少条(prolist.length-noend)和未完成多少条(noend)--><p>共有{{prolist.length}}个目标,{{noend==0?"全部完成了":'已完成'+(prolist.length-noend)+',还有'+noend+'条未完成'}}</p>
    <ul><li class="li1" v-for="(list,index) in newList"><div><span class="status-span" @click="list.status=!list.status" :class="{'status-end':list.status}"></span><span>{{list.name}}</span><span class="close" @click='delectList(index)'>X</span></div></li></ul>

    js

    new Vue({
        el: "#app",
        data: {
            addText:'',
            //name-名称,status-完成状态
           prolist:[
                   {name:"HTML5",status:false},
                   {name:"CSS3",status:false},
                   {name:"vue",status:false},
                   {name:"react",status:false}
            ],
            newList:[]
        },
        computed:{
            //计算属性,返回未完成目标的条数,就是数组里面status=false的条数
            noend:function(){
                return this.prolist.filter(function(item){
                    return !item.status
                }).length;
            }
        },
        methods:{
            addList(){
                //添加进来默认status=false,就是未完成状态
                this.prolist.push({
                    name:this.addText,
                    status:false
                });
                //添加后,清空addText
                this.addText="";
            },
            chooseList(type){
                switch(type){
                    case 1:this.newList=this.prolist;break;
                    case 2:this.newList=this.prolist.filter(function(item){return item.status});break;
                    case 3:this.newList=this.prolist.filter(function(item){return !item.status});break;
                }
            },
            delectList(index){
                //根据索引,删除数组某一项
                this.prolist.splice(index,1);
                //更新newList  newList可能经过this.prolist.filter()赋值,这样的话,删除了prolist不会影响到newList  那么就要手动更新newList
                this.newList=this.prolist;
            },
        },
        mounted(){
            this.newList=this.prolist;
        }
    });
    

    运行结果

    clipboard.png

    步骤4

    文字双击会出现输入框,可输入文字,如果回车或者失去焦点,就改变文字,如果按下ESC就恢复原来的文字

    clipboard.png

    首先.双击出现输入框,就是双击文字后,给当前的li设置一个类名(‘ eidting’),然后写好样式。当li出现这个类名的时候,就出现输入框,并且隐藏其它内容。
    然后.回车或者失去焦点,就改变文字这个只需要操作一个,就是把类名(‘ eidting’)清除掉。然后输入框就会隐藏,其它内容显示!
    最后.按下ESC就恢复原来的文字,就是出现输入框的时候,用一个变量(‘ beforeEditText’)先保存当前的内容,然后按下了ESC,就把变量(‘ beforeEditText’)赋值给当前操作的值!

    代码如下:

    html

    <ul><li class="li1" v-for="(list,index) in newList" :class="{'eidting':curIndex===index}"><div><span class="status-span" @click="list.status=!list.status" :class="{'status-end':list.status}"></span><span @dblclick="curIndex=index">{{list.name}}</span><span class="close" @click='delectList(index)'>X</span></div><input type="text" class="text2" v-model='list.name' @keyup.esc='cancelEdit(list)' @blur='edited' @focus='editBefore(list.name)' @keyup.enter='edited'/></li></ul>

    css(加上)

    li div{display: block;}
    li.eidting div{display: none;}
    li .text2{height: 40px;padding-left: 10px;box-sizing: border-box;margin-left: 10px;width: 80%;display: none;}
    li.eidting .text2{display: block;}

    js

    methods:{
            addList(){
                //添加进来默认status=false,就是未完成状态
                this.prolist.push({
                    name:this.addText,
                    status:false
                });
                //添加后,清空addText
                this.addText="";
            },
            chooseList(type){
                //type=1时,选择所有目标
                //type=2时,选择所有已完成目标
                //type=3时,选择所有未完成目标
                switch(type){
                    case 1:this.newList=this.prolist;break;
                    case 2:this.newList=this.prolist.filter(function(item){return item.status});break;
                    case 3:this.newList=this.prolist.filter(function(item){return !item.status});break;
                }
            },
            delectList(index){
                //根据索引,删除数组某一项
                this.prolist.splice(index,1);
                //更新newList  newList可能经过this.prolist.filter()赋值,这样的话,删除了prolist不会影响到newList  那么就要手动更新newList
                this.newList=this.prolist;
            },
            //修改前
            editBefore(name){
                //先记录当前项(比如这一项,{name:"HTML5",status:false})
                //beforeEditText="HTML5"
                this.beforeEditText=name;
            },
            //修改完成后
            edited(){
                //修改完了,设置curIndex="",这样输入框就隐藏,其它元素就会显示。因为在li元素 写了::class="{'eidting':curIndex===index}"  当curIndex不等于index时,eidting类名就清除了!
                //输入框利用v-model绑定了当前项(比如这一项,{name:"HTML5",status:false})的name,当在输入框编辑的时候,比如改成‘HTML’,实际上当前项的name已经变成了‘HTML’,所以,这一步只是清除eidting类名,隐藏输入框而已
                //还有一个要注意的就是虽然li遍历的是newList,比如改了newList的这一项({name:"HTML5",status:false}),比如改成这样({name:"HTML",status:true})。实际上prolist的这一项({name:"HTML5",status:false}),也会被改成({name:"HTML",status:true})。因为这里是一个对象,而且公用一个堆栈!修改其中一个,另一个会被影响到
                this.curIndex="";
            },
            //取消修改
            cancelEdit(val){
                //上面说了输入框利用v-model绑定了当前项(比如这一项,{name:"HTML5",status:false})的name,当在输入框编辑的时候,比如改成‘HTML’,实际上当前项的name已经变成了‘HTML’,所以,这一步就是把之前保存的beforeEditText赋值给当前项的name属性,起到一个恢复原来值得作用!
                val.name=this.beforeEditText;
                this.curIndex="";
            }
     },

    运行结果

    clipboard.png

    还有一个小细节,大家可能注意到了,就是双击文字,出来输入框的时候,还要自己手动点击一下,才能获得焦点,我们想双击了,输入框出来的时候,自动获取焦点,怎么办?自定义指令就行了!

    computed:{...},
    methods:{...},
    mounted(){...},
    directives:{"focus":{
            update(el){
                el.focus();
            }
        }
    }

    然后html 调用指令

    <input type="text" class="text2" v-model='list.name' @keyup.esc='cancelEdit(list)' @blur='edited' @focus='editBefore(list.name)' @keyup.enter='edited' v-focus/>

    完整代码

    <!DOCTYPE html><html><head><meta charset="UTF-8"><title></title><style>
                body{font-family: "微软雅黑";font-size: 14px;}
                input{font-size: 14px;}
                body,ul,div,html{padding: 0;margin: 0;}
                .hidden{display: none;}
                .main{width: 800px;margin: 0 auto;}
                li{list-style-type: none;line-height: 40px;position: relative;border: 1px solid transparent;padding: 0 20px;}
                li .status-span{display: block;width: 10px;height: 10px;background: #ccc;margin: 14px 10px 0 0 ;float: left;}
                li .status-span.status-end{
                    background: #09f;
                }
                li .close{position: absolute;color: #f00;font-size: 20px;line-height: 40px;height: 40px;right: 20px;cursor: pointer;display: none;top: 0;}
                li:hover{border: 1px solid #09f;}
                li:hover .close{display: block;}
                li div{display: block;}
                li.eidting div{display: none;}
                li .text2{height: 40px;padding-left: 10px;box-sizing: border-box;margin-left: 10px;width: 80%;display: none;}
                li.eidting .text2{display: block;}
                li .text-keyword{height: 40px;padding-left: 10px;box-sizing: border-box;margin-left: 10px;width: 80%;display: none;}
                .text-keyword{box-sizing: border-box;width: 100%;height: 40px;padding-left: 10px;outline: none;}</style></head><body><div id="app" class="main"><h2>小目标列表</h2><div class="list"><h3>添加小目标</h3><input type="text" class="text-keyword" placeholder="输入小目标后,按回车确认" @keyup.13='addList' v-model="addText"/><!--如果noend等于0,就是全部完成了就显示‘全部完成了’,如果没有就是显示已完成多少条(prolist.length-noend)和未完成多少条(noend)--><p>共有{{prolist.length}}个目标,{{noend==0?"全部完成了":'已完成'+(prolist.length-noend)+',还有'+noend+'条未完成'}}</p><p><input type="radio" name="chooseType" checked="true" @click='chooseList(1)'/><label>所有目标</label><input type="radio" name="chooseType" @click='chooseList(2)'/><label>已完成目标</label><input type="radio" name="chooseType" @click='chooseList(3)'/><label>未完成目标</label></p></div><ul><li class="li1" v-for="(list,index) in newList" :class="{'eidting':curIndex===index}"><div><span class="status-span" @click="list.status=!list.status" :class="{'status-end':list.status}"></span><span @dblclick="curIndex=index">{{list.name}}</span><span class="close" @click='delectList(index)'>X</span></div><input type="text" class="text2" v-model='list.name' @keyup.esc='cancelEdit(list)' @blur='edited' @focus='editBefore(list.name)' @keyup.enter='edited' v-focus/></li></ul></div></body><script src="vue2.4.2.js"></script><script type="text/javascript">
        new Vue({
            el: "#app",
            data: {
                addText:'',
                //name-名称,status-完成状态
               prolist:[
                       {name:"HTML5",status:false},
                       {name:"CSS3",status:false},
                       {name:"vue",status:false},
                       {name:"react",status:false}
                ],
                newList:[],
                curIndex:'',
                   beforeEditText:""
            },
            computed:{
                //计算属性,返回未完成目标的条数,就是数组里面status=false的条数
                noend:function(){
                    return this.prolist.filter(function(item){
                        return !item.status
                    }).length;
                }
            },
            methods:{
                addList(){
                    //添加进来默认status=false,就是未完成状态
                    this.prolist.push({
                        name:this.addText,
                        status:false
                    });
                    //添加后,清空addText
                    this.addText="";
                },
                chooseList(type){
                    //type=1时,选择所有目标
                    //type=2时,选择所有已完成目标
                    //type=3时,选择所有未完成目标
                    switch(type){
                        case 1:this.newList=this.prolist;break;
                        case 2:this.newList=this.prolist.filter(function(item){return item.status});break;
                        case 3:this.newList=this.prolist.filter(function(item){return !item.status});break;
                    }
                },
                delectList(index){
                    //根据索引,删除数组某一项
                    this.prolist.splice(index,1);
                    //更新newList  newList可能经过this.prolist.filter()赋值,这样的话,删除了prolist不会影响到newList  那么就要手动更新newList
                    this.newList=this.prolist;
                },
                //修改前
                editBefore(name){
                    //先记录当前项(比如这一项,{name:"HTML5",status:false})
                    //beforeEditText="HTML5"
                    this.beforeEditText=name;
                },
                //修改完成后
                edited(){
                    //修改完了,设置curIndex="",这样输入框就隐藏,其它元素就会显示。因为在li元素 写了::class="{'eidting':curIndex===index}"  当curIndex不等于index时,eidting类名就清除了!
                    //输入框利用v-model绑定了当前项(比如这一项,{name:"HTML5",status:false})的name,当在输入框编辑的时候,比如改成‘HTML’,实际上当前项的name已经变成了‘HTML’,所以,这一步只是清除eidting类名,隐藏输入框而已
                    //还有一个要注意的就是虽然li遍历的是newList,比如改了newList的这一项({name:"HTML5",status:false}),比如改成这样({name:"HTML",status:true})。实际上prolist的这一项({name:"HTML5",status:false}),也会被改成({name:"HTML",status:true})。因为这里是一个对象,而且公用一个堆栈!修改其中一个,另一个会被影响到
                    this.curIndex="";
                },
                //取消修改
                cancelEdit(val){
                    //上面说了输入框利用v-model绑定了当前项(比如这一项,{name:"HTML5",status:false})的name,当在输入框编辑的时候,比如改成‘HTML’,实际上当前项的name已经变成了‘HTML’,所以,这一步就是把之前保存的beforeEditText赋值给当前项的name属性,起到一个恢复原来值得作用!
                    val.name=this.beforeEditText;
                    this.curIndex="";
                }
            },
            mounted(){
                //初始化,把prolist赋值给newList。默认显示所有目标
                this.newList=this.prolist;
            },
            directives:{
            "focus":{
                update(el){
                    el.focus();
                }
            }
        }
        });</script></html>

    6.小结

    好了,三个小实例在这里就说完了!别看文章这么长,其实都是基础,可能是我比较啰嗦而已!如果大家能熟透这几个小实例,相信用vue做项目也是信手拈来。基础的语法在这里了,有了基础,高级的写法也不会很难学习!如果以后,我有什么要分享的,我会继续分享。最后一句老话,如果觉得我哪里写错了,写得不好,欢迎指点!

    前后端完全分离之API设计

    $
    0
    0

    背景

    API就是开发者使用的界面。我的目标不仅是能用,而且好用, 跨平台(PC, Android, IOS, etc…)使用; 本文将详细介绍API的设计及异常处理, 并将异常信息进行封装友好地反馈给前端.

    上篇文章 前后端完全分离初探只是讲了些宽泛的概念, 接下来的文章将直接上干货, 干货的源码会挂在 github上.

    前后端完全分离后, 前端和后端如何交互?

    答: 通过双方协商好的API.

    接下来我分享我自己设计的API接口, 欢迎各位朋友指教.

    API设计理念

    1. 将涉及的实体抽象成资源, 即按 id访问资源, 在 url上做文章, 以后再也不用为 url起名字而苦恼了.
    2. 使用 HTTP动词对资源进行 CRUD(增删改查); get->查, post->增, put->改, delete->删.
    3. URL命名规则, 对于资源无法使用一个单数名词表示的情况, 我使用中横线( -)连接.
      • 资源采用名词命名, e.g: 产品 -> product
      • 新增资源, e.g: 新增产品, url -> /product , verb -> POST
      • 修改资源, e.g: 修改产品, url -> /products/{id} , verb -> PUT
      • 资源详情, e.g: 指定产品详情, url -> /products/{id} , verb -> GET
      • 删除资源, e.g: 删除产品, url -> /products/{id} , verb -> DELETE
      • 资源列表, e.g: 产品列表, url -> /products , verb -> GET
      • 资源关联关系, e.g: 收藏产品, url -> /products/{id}/star , verb -> PUT
      • 资源关联关系, e.g: 删除收藏产品, url -> /products/{id}/star , verb -> DELETE

    目前我API的设计只涉及这两点, 至于第三点 HATEOAS(Hypermedia As The Engine Of Application State)那就由读者自己去选择了.

    项目地址

    本文中只涉及了设计的理念, 具体的实现请下载源码 https://github.com/arccode/rest-api, 项目内写了比较详细的注释.

    项目实战

    实战将从业务场景出发, 详细介绍如何使用HTTP verb对资源进行操作( 状态转移), 使用JSON返回结果( 资源表述), 并定义JSON的基础结构.

    JSON结构

    requestParams:

    1     
    2
    {     
    }

    responseBody:

    1     
    2
    3
    4
    5
    6
    {     
    "meta": {
    },
    "data": {
    }
    }

    meta中封装操作成功或失败的消息, data中封装返回的具体数据.

    当新建商品或更新产品时, 相关属性封装在JSON中, 通过POST或PUT发送,

    1     
    2
    3
    4
    {     
    "name": "Apple Watch SPORT",
    "description": "Sport 系列的表壳材料为轻巧的银色及深空灰色阳极氧化铝金属,强化 Ion-X 玻璃材质为显示屏提供保护。搭配高性能 Fluoroelastomer 表带,共有 5 款缤纷色彩。"
    }

    当用户对商品进行操作后, 将得到响应结果,

    GET, POST, PUT操作成功, 返回如下结果

    1     
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {     
    "meta": {
    "code": 201,
    "message": "创建成功"
    },
    "data": {
    "id": "5308e9c2-a4ce-4dca-9373-cc1ffe63d5f9",
    "name": "Apple Watch SPORT",
    "description": "Sport 系列的表壳材料为轻巧的银色及深空灰色阳极氧化铝金属,强化 Ion-X 玻璃材质为显示屏提供保护。搭配高性能 Fluoroelastomer 表带,共有 5 款缤纷色彩。"
    }
    }

    DELETE操作成功, 返回如下结果

    1     
    2
    3
    4
    5
    6
    {     
    "meta": {
    "code": 204,
    "message": "删除成功"
    }
    }

    业务场景一

    电商网站的管理员对商品进行新增,编辑,删除,浏览的操作; 暂时不考虑认证授权, 只关注对商品的操作.

    为了以后便于做分布式, 所有资源id(表主键)均采用uuid.

    新增商品

    1, url: /api/product

    2, method: POST

    3, requestParams:

    1     
    2
    3
    4
    {     
    "name": "Apple Watch SPORT",
    "description": "Sport 系列的表壳材料为轻巧的银色及深空灰色阳极氧化铝金属,强化 Ion-X 玻璃材质为显示屏提供保护。搭配高性能 Fluoroelastomer 表带,共有 5 款缤纷色彩。"
    }

    4, responseBody

    1     
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {     
    "meta": {
    "code": 201,
    "message": "创建成功"
    },
    "data": {
    "id": "5308e9c2-a4ce-4dca-9373-cc1ffe63d5f9",
    "name": "Apple Watch SPORT",
    "description": "Sport 系列的表壳材料为轻巧的银色及深空灰色阳极氧化铝金属,强化 Ion-X 玻璃材质为显示屏提供保护。搭配高性能 Fluoroelastomer 表带,共有 5 款缤纷色彩。"
    }
    }

    编辑商品

    1, url: /api/products/{id}

    2, method: PUT

    3, requestParams:

    1     
    2
    3
    4
    {     
    "name": "iPhone 6",
    "description": "此次苹果发布会发布了iPhone 6与iPhone 6 Plus,搭载iOS 8,尺寸分别是4.7和5.5英寸。外观设计不再棱角分明,表层玻璃边有一个弧度向下延伸,与阳极氧化铝金属机身边框衔接。机身背部采用三段式设计。机身更薄,续航能力更强。"
    }

    4, responseBody

    1     
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {     
    "meta": {
    "code": 200,
    "message": "修改成功"
    },
    "data": {
    "id": "5308e9c2-a4ce-4dca-9373-cc1ffe63d5f9",
    "name": "iPhone 6",
    "description": "此次苹果发布会发布了iPhone 6与iPhone 6 Plus,搭载iOS 8,尺寸分别是4.7和5.5英寸。外观设计不再棱角分明,表层玻璃边有一个弧度向下延伸,与阳极氧化铝金属机身边框衔接。机身背部采用三段式设计。机身更薄,续航能力更强。"
    }
    }

    删除商品

    1, url: /api/products/{id}

    2, method: DELETE

    3, responseBody

    1     
    2
    3
    4
    5
    6
    7
    {     
    "meta": {
    "code": 204,
    "message": "删除成功"
    },
    "data": {}
    }

    获取商品详情

    1, url: /api/products/{id}

    2, method: GET

    3, responseBody

    删除前

    1     
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {     
    "meta": {
    "code": 200,
    "message": "查询成功"
    },
    "data": {
    "id": "5308e9c2-a4ce-4dca-9373-cc1ffe63d5f9",
    "name": "Apple Watch SPORT",
    "description": "Sport 系列的表壳材料为轻巧的银色及深空灰色阳极氧化铝金属,强化 Ion-X 玻璃材质为显示屏提供保护。搭配高性能 Fluoroelastomer 表带,共有 5 款缤纷色彩。"
    }
    }

    删除后

    1     
    2
    3
    4
    5
    6
    {     
    "meta": {
    "code": 404,
    "message": "指定产品不存在"
    }
    }

    获取商品列表(未分页)

    1, url: /api/products

    2, method: GET

    3, responseBody

    1     
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    {     
    "meta": {
    "code": 200,
    "message": "获取全部商品成功"
    },
    "data": [
    {
    "id": "5308e9c2-a4ce-4dca-9373-cc1ffe63d5f9",
    "name": "Apple Watch SPORT",
    "description": "Sport 系列的表壳材料为轻巧的银色及深空灰色阳极氧化铝金属,强化 Ion-X 玻璃材质为显示屏提供保护。搭配高性能 Fluoroelastomer 表带,共有 5 款缤纷色彩。"
    },
    {
    "id": "9db1992a-c342-4ff0-a2a4-aeb3dbfd93f6",
    "name": "Apple Watch SPORT",
    "description": "Sport 系列的表壳材料为轻巧的银色及深空灰色阳极氧化铝金属,强化 Ion-X 玻璃材质为显示屏提供保护。搭配高性能 Fluoroelastomer 表带,共有 5 款缤纷色彩。"
    },
    {
    "id": "4481619b-45c5-4729-9539-f93bb01f10d8",
    "name": "Apple Watch SPORT",
    "description": "Sport 系列的表壳材料为轻巧的银色及深空灰色阳极氧化铝金属,强化 Ion-X 玻璃材质为显示屏提供保护。搭配高性能 Fluoroelastomer 表带,共有 5 款缤纷色彩。"
    }
    ]
    }

    业务场景二

    业务场景一中只涉及了单个资源的操作, 但实际场景中还有些关联操作; 如用户去电商网站浏览商品, 并收藏了一些商品, 之后又取消收藏了部分商品.

    暂时不考虑用户认证授权, 以后加了 token后, 用户信息可以从中获取.

    收藏商品

    1, url: /api/products/{id}/star

    2, method: PUT

    3, responseBody

    1     
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    {     
    "meta": {
    "code": 200,
    "message": "收藏商品[5308e9c2-a4ce-4dca-9373-cc1ffe63d5f9]成功"
    },
    "data": [
    {
    "id": "5308e9c2-a4ce-4dca-9373-cc1ffe63d5f9",
    "name": "iPhone 6",
    "description": "此次苹果发布会发布了iPhone 6与iPhone 6 Plus,搭载iOS 8,尺寸分别是4.7和5.5英寸。外观设计不再棱角分明,表层玻璃边有一个弧度向下延伸,与阳极氧化铝金属机身边框衔接。机身背部采用三段式设计。机身更薄,续航能力更强。"
    }
    ]
    }

    取消收藏商品

    1, url: /api/products/{id}/star

    2, method: DELETE

    3, responseBody

    1     
    2
    3
    4
    5
    6
    7
    {     
    "meta": {
    "code": 200,
    "message": "删除收藏商品[5308e9c2-a4ce-4dca-9373-cc1ffe63d5f9]成功"
    },
    "data": []
    }

    自定义异常和异常处理

    所有自定义异常继承RuntimeException, 在业务层抛出, 统一在Controller层进行处理.

    异常分为全局异常和局部异常, 例如http method unsupported(405), unauthorized(401), accessDenied(403), not found(404)等属于全局异常; 针对对独立业务的一些异常属于局部异常, 例如产品编辑出错;

    异常在Controller中进行处理, 并封装成json返回给前端, 封装后的数据如下, 相关实现见 源码;

    1     
    2
    3
    4
    5
    6
    {     
    "meta": {
    "code": 404,
    "message": "指定产品不存在"
    }
    }
    1     
    2
    3
    4
    5
    6
    {     
    "meta": {
    "code": 405,
    "message": "Request method 'POST' not supported"
    }
    }

    项目运行截图部分

    本系列文章

    • 前后端完全分离初探
    • 前后端完全分离之API设计
    • 前后端完全分离之安全认证与授权-上
    • 前后端完全分离之安全认证与授权-下
    • 前后端完全分离之前端模块化开发
    • 前后端完全分离之前端路由系统
    • 前后端完全分离之后端面向服务的模块化开发

    现代浏览器性能优化-CSS篇

    $
    0
    0

    我来填坑了,CSS篇终于写出来了,如果你没看过前面的JS篇,可以 在这里观看

    众所周知,CSS的加载会阻塞浏览器渲染或是引起浏览器重绘,目前业界普遍推荐把CSS放到 <head>中,防止在CSS还没加载完,DOM就已经绘制出来了,造成CSS加载完成后的重绘。那在现代浏览器中我们有没有办法提高首屏渲染速度那?

    你是不是经常在第一次打开某个网站的时候看到这种情况,本来的页面是这样的

    实际上刚加载出来的是这样的

    字体文件没加载出来,或者加载的太慢了

    理解CSS解析过程

    以下面这段HTML为例,解释一遍CSS加载解析的过程。

    <html><head><!-- headStyle.css中存在字体文件webfont.woff2 --><link rel="stylesheet" type="text/css" href="/headStyle.css"></head><body><p>Text</p><link rel="stylesheet" type="text/css" href="/bodyEndStyle.css"></body></html>

    浏览器自上而下读取HTML文档,当发现headStyle.css的时候,停止Parser HTML,开始下载headStyle.css,解析headStyle.css的过程中发现字体文件webfont.woff2,开始下载webfont.woff2,并继续解析css生成CSSStyleSheet。解析完毕后,继续Parser HTML,当发现p标签时,会将p标签结合当前的CSSStyleSheet展示出来,此时用户屏幕中已经有p标签的内容了。当浏览器发现bodyEndStyle.css时,就会下载headStyle.css,解析CSS,然后更新CSSStyleSheet,这时会引起一次重绘。当字体下载完毕的时候也会引起一次重绘。

    这个过程中,有两个非常严重的问题。一、如果headStyle.css文件很大,浏览器需要解析很多行CSS后才能还有个字体文件需要下载,其实此时已经很晚了,字体下载时间稍长一点,就会出现我前面截图提到的问题。二、bodyEndStyle.css中如果存在p标签对应的样式,那p标签的样式会在bodyEndStyle.css解析完成后,改变一次样式,很影响体验。

    如何解决这些问题那?其中也会用到一些JS篇中提到的点,如果没看过,建议先看看。

    优化核心依旧是减少下载时间

    JS篇中的预先解析DNS(dns-prefetch)依旧适用,提前解析CSS文件所在域名的DNS。

    Preload

    因为CSS已经在head中,我们不需要为css加preload属性了,但是css中用到的字体文件,一定要在所有css之前proload上。

    <link rel="preload" href="/webfont.woff2" as="font">

    首页CSS内联,非必要CSS异步加载

    首页用到的CSS内联写在 <head>中,其余CSS均采用异步加载,可以采用这种自己实现的加载CSS的方法,在合适的需要时加载需要的css

    function LoadStyle(url) {
      try {
        document.createStyleSheet(url)
      } catch(e) {
        var cssLink = document.createElement('link');
        cssLink.rel = 'stylesheet';
        cssLink.type = 'text/css';
        cssLink.href = url;
        var head = document.getElementsByTagName('head')[0];
        head.appendChild(cssLink)
      }
    }

    如果你使用webpack,那就更轻松了,使用import函数,大致如下

    // 在a.js模块中直接引入css
    import 'style.css'
    // 在需要a.js模块的地方
    improt('path-of-a.js').then(module => {})

    webpack打包后,其实是把style.css打包进了a.js,在异步加载a.js的时候,会将style.css中的代码插入 haed标签中。

    终极完美结构

    <!DOCTYPE html><html><head><meta charset="utf-8"><title>Faster</title><link rel="dns-prefetch" href="//cdn.cn/"><link rel="preload" href="//cdn.cn/webfont.woff2" as="font"><link rel="preload" href="//cdn.cn/Page1-A.js" as="script"><link rel="preload" href="//cdn.cn/Page1-B.js" as="script"><link rel="prefetch" href="//cdn.cn/Page2.js"><link rel="prefetch" href="//cdn.cn/Page3.js"><link rel="prefetch" href="//cdn.cn/Page4.js"><style type="text/css">
        /* 首页用到的CSS内联 */
      </style></head><body><script type="text/javascript" src="//cdn.cn/Page1-A.js" defer></script><script type="text/javascript" src="//cdn.cn/Page1-B.js" defer></script></body></html>

    JS篇)中,我已经解释过这套结构中JS的执行顺序了,本篇只是加入了CSS和字体。至此,我心中终极完美的页面HTML结构就是这样了。

    如果你对异步加载CSS的方案感兴趣,欢迎留言与我讨论!

    扩展阅读

    Viewing all 148 articles
    Browse latest View live


    <script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>