作者: 一个 AI

  • 配置前端开发环境

    配置前端开发环境

    摘要

    无论是否是前端,作为一个程序员,我们在拿到新电脑的时候,首先需要完善我们的开发环境

    一些特殊配置

    有些网站的访问(如 Github)是存在不稳定的或者是被拦截的状态,对于这种网站,我们又是需要访问的情况,那么就需要一些特殊手段来访问。

    Watt toolkit

    这个工具箱提供一些网站的访问,如 Github,原理是通过修改 Hosts 文件的域名映射指向具体的 IP 地址,从而无需通过 DNS 解析服务获取域名对应的 IP 地址(网站拦截的一个原理)。

    这种方式访问依旧不太稳定,速度也不快,但相对的,不会出现类似下图这种页面。

    开发环境配置

    Git 配置

    安装

    公司有自建 GitLab 仓库,但使用的依旧是 Git 工具,所以需要下载 windows 对应的 Git 工具包。Git – Downloading Package 访问该页面,下载 x64 版本。

    这里需要使用上面的 Watt toolkit 保证你可以访问 Github,从而安装 Git 命令行工具。

    当然,如果你的网络环境是可以正常访问的,那就不需要通过第三方工具去访问git了。

    基本配置

    打开 gitconfig,windows 建议通过 gitbash 打开对应的配置内容。

    cd ~
    # 编辑 gitconfig 文件,code 命令通过 VSCode 打开文件
    code .gitconfig

    .gitconfig 的内容如下

    [core]
    	editor = \"D:\\Microsoft VS Code\\bin\\code\" --wait
    	autocrlf = true
    [safe]
    	directory = 信任的文件夹
    [user]
    	email = 自己的邮箱
    	name = 自己的名称
    [alias]
        lg = log --graph --abbrev-commit --pretty=format:'%C(red)%h%Creset -%C(yellow)%d%Creset %s %C(green)(%cr) %C(bold blue)<%an>%Creset' --all
        co = checkout
        br = branch
        ci = commit
        st = status
        pl = pull --rebase
        pr = pull --rebase
        ps = push
        dt = difftool 
        l = log --stat
        cp = cherry-pick
        ca = commit -a
    [core]
        editor = code -w
        ignorecase = false

    配置中有一个 autocrlf 的内容,这个配置是 Git 中用于处理 换行符转换 的重要配置,主要解决不同操作系统(如 Windows、macOS、Linux)对换行符处理方式不一致的问题。

    建议在 windows 环境下建议设置为 true:提交时将 CRLF 转换成 LF,本地检查到的时候转换成 CRLF,从而使得远端仓库中的在其他环境下可用。

    Linux/MacOS 环境下设置为 input:提交时将 CRLF 转换成 LF(避免不一致),本地检出时,不转换。

    SSH 配置

    工具套件

    nvm 套件

    nvm 套件是前端必备的 Node 包管理器,我们经常会存在不同项目使用不同的 node 版本的情况,这种情况下,有 NVM 就可以快速的帮助我们进行 Node 版本的切换。

    套件有两种版本:nvm nvm-for-windows

    nvm 是提供给 Linux/MacOS 的,我们可以通过系统的包管理器 brewapt-get 安装。

    nvm-for-windows 是提供给 windows 用户的,是 nvm 的一个衍生分支,因为操作系统的差异性,所以使得 windows 用户无法像 Linux 用户那样很便捷的使用命令行工具,缺少一些专业性,但是也因此,在软件开发者提供 exe 安装包后,我们可以无脑安装及配置。

    在写这篇文章的时候,Node LTS 版本还是 v22,代号为 Jod。并且还在提供维护的版本是 v20,代号为 Iron。正常情况下,我们直接使用 LTS 版本就可以了,但如果你是要运行老的项目,可能对 Node 版本有要求,可以按需安装,然后在对应的项目下使用 nvm 命令进行切换。

    TLS版本
    NVM 切换命令

    在项目中,你可以选择使用 .nvmrc 配置使用的 Node 版本,这样,其他人(包括在构建镜像的时候),可以不需要手动指定版本,只需要在目录下执行 nvm 就可以自动进行切换。

    NPM 镜像源

    如果你有私有化部署的镜像源(公司或个人),就可以考虑使用私有的镜像源地址,否则,可以考虑使用国内其他大厂开放的镜像源,目前可供选择的镜像源主要有:

    1. taobao 镜像源
    2. 腾讯镜像源

    切换方式主要有:全局、项目

    全局切换会对所有项目生效:

    npm config set registry https://registry.npmmirror.com

    如果想要恢复官方镜像源:

    npm config set registry https://registry.npmjs.org

    项目维度的配置,就是在对应项目下,新增文件:.npmrc

    registry=https://mirrors.cloud.tencent.com/npm/

    值得一提的是,yarn 只有 1.x 版本才会识别这个文件,2.x 版本后不再识别。

    开发软件

    终端工具

    Windows Terminal

    微软出品,集成在 Windows11 中,但是 Windows10 也能用,要比以前的 CMD、PowerShell默认的面板强很多,但他只是终端工具,不是shell,所以他还是集成的 PowerShell(默认集成)。

    Tabby

    第三方,个人在 MacOS 中使用,非常好用的一个终端工具

    IDE 编辑器

    VS Code

    【推荐】VS Code 是微软的开源软件,具有较为强大的插件生态。

    Trae

    【推荐】是字节跳动AI团队基于 VS Code 开源项目集成了DeepSeek、豆包等大模型的定制版 IDE。

    IDEA Webstorm

      IDEA 公司为前端开发提供的专业软件,但是不提供社区版,功能很强大,但是收费的。

      其他提效软件

      7z

      国内的压缩包大多是基于 7z 套壳,7z 就是 UI 丑了点,但是没有打不开的压缩文件。

      Snipaste

      截图软件,即使E+已经自带的截图够用,也推荐使用该软件。

      豆包 App

      替代搜索引擎,可以直接问答案,比较快捷,但是这只是其中一种 AI 工具,你可以根据自身的喜好安装对应的 AI 工具(kimi、Claude、ChatGPT等)

    1. 浅浅的聊一下 WebSocket

      第一次看到 ws://wss:// 时候,感觉好高级啊,还有这种协议。

      Websocket 历史

      WebSocket 是在2008年6月诞生的1。经由 IEFT 标准化后,2009年 chrome 4 第一个提供了该标准支持,并默认启用。于2011年由 IEFT 标准化为 RFC 6455

      现在的浏览器均已支持该标准。

      Websocket 出现的背景

      思考一下我们经常遇到的一种需求场景,要求在某个网页下,网页的内容可以实时更新。

      这种情况下,最大众化的方式就是轮询接口了,即通过定时器,定时请求接口,获取到最新的信息后,将内容更新到页面中,如下:

      setInterval(() => {
        queryAPI().then(() => update());
      }, 1000);
      

      但是我们知道,这种定时器的延时并不是很精确,而且加上接口的请求时延,实际时间可能不止代码中所预先设定的时间长度,所以这种实时更新是伪实时更新。

      除此之外,还有一点可能会经常遇到,即,我们更新信息并总是要更新整个页面上所有可以看到的信息,我们更关注一些经常变化的信息,比如状态,状态的信息可能大小只有几个字节,但是我们轮询接口拿到的信息却是这个页面的所有信息,大小自然不只几个字节,但是除状态以外的信息都可以视作是冗余的。

      我们实际只需要一个字段,而且即使后端提供只返回状态的接口,但实际在一个请求中还要计算 IP 报文头的大小,依旧是很占用带宽的。

      轮询这种解决方案目前依旧是非常流行,最新的轮询技术是 Comet,这种技术虽然可以实现双向通信,但仍然需要反复发出请求。而且在 Comet 中普遍采用的 HTTP长连接也会消耗服务器资源2。

      Websocket 通信模式

      了解网络的都知道,数据传输分为单工、半双工、全双工三种工作模式。

      Websocket 是基于 TCP 的,使用全双工通信模式的协议,他使得客户端和服务端之间的数据交换变得更简单。而且,作为一个工作在全双工模式下的协议,服务端可以在建立连接后随时向客户端推送消息。

      由于协议是基于 TCP 的,所以 websocket 也是需要建连和关闭连接的,但要注意的是,一般在 websocket 的握手通常指的是:客户端发送一个 http 请求到服务端,服务端响应后标志这个链接建立起来。而不是指 tcp 的三次握手。

      另外在 RFC 6455 1.1节「Background」中介绍:WebSocket 通过 HTTP 端口的80和443进行工作,并支持 HTTP 代理和中介。

      原文:it is designed to work over HTTP ports 80 and 443 as well as to support HTTP proxies and intermediaries

      为了实现和 HTTP 的兼容性,WebSocket 握手使用 HTTP 的 Upgrade 头将 HTTP 协议转换成 Websocket 协议。

      作为一种协议,websocket 自然也是有其用于协议控制的头部信息的,但是相对于 HTTP 请求每次都要带上完整的头部信息,传输数据时,websocket 数据包的头部信息就相对较小,从而降低了控制开销。

      相对于前文所提到的轮询接口,websocket 可以做到服务端直接向客户端传输数据,省去了客户端发起请求的步骤,同时没有间隔时间,只要服务端内容变化,就可以告知客户端,实时性上有了很大的提高。

      Websocket 使用

      WebSocket使用十分简单,只需要关注以下API:

      1. WebSocket(url[, protocol]) 构造函数

      使用 new WebSocket(xxx) 创建对象时,会同时建立与服务器的连接

      1. WebSocket.onopen , WebSocket.onclose

      分别对应连接成功、失败时的回调,这里可以做一些初始化、销毁的工作

      1. WebSocket.onmessage

      实际处理数据是用的该函数

      在数据处理完成后,需要移除回调函数,不然可能会影响到其他地方的处理

      const ws = new WebSocket('ws://sdf.com');
      function handleData(evt) {
        // handle server data.
      }
      ws.onmessage = handleData;
      ws.addEventListener('message', handleData);
      
      1. WebSocket.send

      主动向服务端发送消息,可以通过 send 和 onmessage 进行数据互动,如:

      ws.send('list');
      
      ws.onmessage = evt => {
        const data = evt.data;
        if (data === 'hello') {
          console.log('world');
          return ;
        }
        try {
          const obj = JSON.parse(data);
          switch (obj.type) {
            case 'list':
              // do something.
          }
        } catch (ex) {}
      }
      
      1. WebSocket.close

      关闭连接

      Node服务端的实现,这个就参考相关的库吧,比较复杂。

      衍生知识

      http 协议至今,主要经历了三个版本。

      • http1.0 短连接,单工通信

        • http/1.0 默认的模型是短连接,每个HTTP请求都由他自己独立完成,下图左1,可以看到每一个 http 请求都对应了一个建立连接关闭连接的阶段,每一个请求都有TCP握手和挥手的阶段。
        • 在这个模型下,想要做到实时更新页面数据,只能考虑轮询。
      • http1.1 支持长链接,半双工通信

        • 1.0之后的版本,1.1会让某个连接保持一定的时间,在这段时间里重复发送一系列请求(下图左2),就是保活。
      • http2.0 支持多路复用,全双工通信

      参考文献

      [1]  [whatwg] TCPConnection feedback

      [2]  wiki

      [3]  RFC 6455

      [4]  Websocket教程

      [5]  HTTP1.x连接管理

    2. 浅谈前端水印

      浅谈前端水印

      又是一个有关安全的问题。

      一般情况下,我们说的水印是指图片角落上的平台用户名水印。类似于下方图片上的这种,通常只要将图片上传到平台上,平台就会在图片上嵌入水印,当然,有些平台也会提供设置是否需要显示这种水印的开关,或者设置保存的时候才会加上水印。

      明水印

      这种水印的实现其实是比较简单的,就是将两张图片合成一张,或者是直接在原图上绘制内容就行了,对应的 HTML 代码:

      <img id="pic" src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f3c3c98ebfce4ae28db981dfabedc1d8~tplv-k3u1fbpfcp-zoom-1.image" alt="原始图片" height="500" crossorigin="anonymous">
      <div>Photo by Claudio Schwarz | @purzlbaum on Unsplash</div>

      对应 JavaScript 代码:

      window.onload = () => {
          const pic = document.querySelector('#pic');
          const canvasNode = document.createElement('canvas');
          const picWithWatermark = createImageWithWatermark(pic, canvasNode);
          pic.src = picWithWatermark;
      }
      
      
      /**
       * 创建带水印的图片
       * create image with watermark.
       * @param {HTMLImageElement} img 图片结点 - image element.
       * @param {HTMLCanvasElement} canvas canvas结点 - canvas element.
       * @returns 处理后的图片 base64 - pic with watermark.
       */
      const createImageWithWatermark = (img, canvas) => {
          const imgWidth = img.width;
          const imgHeight = img.height;
          canvas.width = imgWidth;
          canvas.height = imgHeight;
      
          const ctx = canvas.getContext('2d');
          ctx.drawImage(img, 0, 0, imgWidth, imgHeight);
          ctx.font = '16px YaHei';
          ctx.fillStyle = 'black';
          ctx.fillText('Photo by Claudio Schwarz | @purzlbaum on Unsplash', 20, 20);
      
          return canvas.toDataURL('image/jpg');
      }

      以上就是完整的代码了,更详细的代码可以访问 github 链接查看

      普通用户所说的水印就是上面这种了,但是对于开发者来说,水印所包含的分类还是比较多的。

      如我们在公司内网的部分系统(也可能是所有)上就能看到这种水印。

      这里水印颜色选择黑色只是为了能更直观的看到效果,真实使用这种水印的时候,都会选用白色透明的。

      这种水印就有点类似之前所说的,将两张图片合成一个的那种方式,只不过,在前端页面上,我们是使用一个透明的 canvas 容器覆盖整个页面,然后在 canvas 中绘制这个“标识”,用来标识访问当前页面的用户身份,这样一来,无论是你截图还是拍照,只要图片上能看到水印,我们就能根据这个水印去追踪到泄露这部分信息的人。

      那可能会有人问,那我知道这个水印是一个 dom 结点了,打开控制台找到他,删了不就好了?

      明水印的防御

      这确实是好问题,不过也不是什么大的问题,你想删,这是完全可以的。

      我控制不了你的行为,但是我可以检测到你操作了这个 dom 结点,那不好意思,我不管你怎么操作的这个结点,为了安全,我肯定都要重新绘制这个水印的。

      但光重新绘制水印我觉得还不够,这可能会让你跟我拼速度的,那不行啊,我必须给你点教训的,还不能让你得偿所愿,怎么办?只要你操作了我的 dom,那么我直接让页面白屏,然后再重载页面。这也就达成了禁止用户操作 dom 结点的方式了。

      要实现这个,我们需要借助 js 提供的 MutationObserver函数,这个函数可以监听容器的变化。

      代码如下:

      // 容器监听的回调
      const cb = function (mutationList, observer) {
          for (const mutation of mutationList) {
              if (mutation.type === 'childList') {
                  const { removedNodes = [] } = mutation;
                  // 如果监听到水印容器变化,那么就清空页面并重载
                  const node = Array.prototype.find.apply(removedNodes, [(node => node.id === 'page-watermark')])
                  if (node) {
                      targetNode.innerHTML = '';
                      window.location.reload();
                  }
              }
          }
      }
      // 目标DOM结点
      const targetNode = document.querySelector('#watermark-body');
      // 创建监听
      const observer = new MutationObserver(cb);
      observer.observe(targetNode, {
          attributes: true,
          childList: true
      });

      MutationObserver 是 DOM3 Event 规范的一部分,用于替代旧的 Mutation Events,可以放心使用。

      虽然上面的是全局水印,但是你也可以只对一部分内容加水印,只不过全局水印实现成本更低,代价小,对于内网系统来说,牺牲这点用户体验,并不能算是什么非常严重的问题,是可以接受的。

      可能有人又要说了,我都打开 dom 了,那我研究一下这个 dom 结构,写个爬虫去爬数据,或者直接复制 dom 里面的内容不就好了,你这水印还有啥存在的意义吗?

      无法反驳,但是要说明一点的是,爬数据这个是违法的,要负法律责任,而且你爬虫肯定是要运行在某个电脑上的,这就不需要水印了,我们可以直接查 IP,追踪到对应的人就行了,而我们加的水印不过就是一个方便追踪的工具而已。

      其次,前端和爬虫斗智斗勇,你从网页爬数据,那我就想办法不直接生成文字,而是把一些关键词给替换成图片,这样一来,你爬虫爬到的结果,就是一串没有用的文字。

      这就扯到反爬虫的事情上了。言归正传,到目前为止,我们一直都在讨论明水印,对于内网来说,使用这种水印肯定是没什么问题的,但是对外的网站怎么办呢?如果也加上这种明水印,显然不太合适,想要在这里牺牲用户体验就是不能接受的。

      所以我们就开始考虑,能不能加上一个肉眼看不见的水印呢?

      暗水印

      当然是没问题的,这就是我们下面要说的暗水印。

      听名字就知道,暗水印和明水印是刚好相反的,我们看不见这种水印,而且这种水印无论是原理还是实现,和明水印的差别都是比较大的。

      先看看原理。

      不知道你有没有听说过,隐写术[1]。对于这个比较玄幻的名词,wiki 是这么描述的: “隐写术是一门关于信息隐藏的技巧与科学,所谓信息隐藏指的是不让除预期的接收者之外的任何人知晓信息的传递事件或者信息的内容。”,究其本质,还是密码学那一套。

      追加文件内容

      我们可以通过各种方式将信息写到图片,最常见的应该是将需要隐写的内容以二进制的形式写入图片中,咱们在这里举个简单的例子,以下面的图片为例:

      这是我们开篇引用的图片,记为原始图像,将图片保存在本地后(original.png),执行命令:

      tail -c 50 1.png

      可以看到执行结果里面是一串乱码(用Hex查看器可以看到文件的二进制码流,这里时utf-8,乱码是正常的),对该文件执行命令:

      cat original.png > result.png
      echo testWrite >> result.png
      tail -c 50 result.png

      我们生成一张新的图片之后,将一串字符追加到图片末尾,可以看到图片依旧是正常显示的,同时查看图片的内容,可以看到刚才写入的 testWrite 字符串:

      另外,将字符串加到文件头部是不行的,因为文件头部包含了文件格式等信息。如果你把信息插入到文件头部,市面上的软件就无法正确的识别文件的类型。

      这只是一种方式,而且手段十分暴力,处理之后的图片文件较原来的文件是有一定的大小变化的(不过比较小,可以按字节计算)。更聪明的做法是将加密的信息按照某种模式写入图片的二进制流中,这样一来,就只有加密方才能拿到对应的信息了。

      但即使有复杂的加密方式,也还是不够的,因为这只能保证别人在使用原始图片的时候,我们可以鉴别图片的来源、流传路线,但要是通过屏幕截图或者拍照的方式,我们就无法拿到这个数据,因为此时相对于我们做过处理的图片,他已经是一张全新的图片了。

      修改RGB分量值

      来看另一个例子,RGB分量值的小量变动:在图片上覆盖一层肉眼看不见的图片,简单来说就是我可以在图片的某个单通道(如rgb中的b通道)内将水印信息写入,其实这么说也还是很难懂,举个例子:

      现在要将左右两侧的图片组合,但是不能让右侧的图片内容在左侧的图片上观察到,这时候我们要做的就是按照一定规则将水印图片写进这张图片的rgb通道内。

      预处理,先生成右侧的水印图
      
      编码
      1. 通过canvas获取到两张图片的rgba数据
      2. 将左侧图片的b(蓝色)通道值-1,即,b & 0xfffffffe
      3. 读取右侧b通道数据,遇到大于0的值,就将左侧对应位置处的b通道值 +1,即,b | 0x00000001
      
      解码
      1. 获取图片的rgba数据
      2. 读取b通道数据,遇到 b & 0x00000001 > 0 的数据,说明有水印信息,将其置为255,除a通道(alpha通道不是颜色通道)外,其余通道的数据全部置为0
      
      
      // +1,-1 是因为量级的变化极小,并不会影响到图片的显示

      其实黑底蓝字的图片就是解码出来的水印数据,详细代码:https://github.com/ai977313677/blog/blob/master/snippet/index.html

      好像这种方式可以在用户截图时也能够保留我们的水印?其实并没有。

      这是解码截图的结果,可以明显的看到,QQ 截图之后的图片并没有能够解码出来我们所需要的水印内容,甚至于将图片压缩之后,可能就会失去我们的水印,所以说这其实也并不是一个可靠的水印方式。

      那如何才能保证我们的水印至少在截图的时候也能发挥作用呢?

      也不是不行,首先确定我们水印要加在哪里(确定需求),因为图片来源无非是网页搜索结果,或者说我们截得图多数来自于网页,所以我们考虑的是在网页上覆盖一层水印,保证用户从网页上截取的图片可以被我们追踪到来源。

      这个通用的解决方案依旧是写css,只不过这时候我们将背景图置顶,同时将其透明度设置的很低。

      代码很简单,其实就是将一张背景图片铺满整屏就可以了,然后将opacity设置到肉眼无法观察到的程度就OK了:

      window.onload = () => {
          const width = document.body.clientWidth;
          const height = document.body.clientHeight;
      
          const maskDiv = document.createElement('div');
          maskDiv.id = 'mask_watermark';
          maskDiv.style.position = 'absolute';
          maskDiv.style.backgroundImage = 'url(./1.jpg)';
          maskDiv.style.backgroundRepeat = 'repeat';
          maskDiv.style.visibility = '';
          maskDiv.style.left = '0px';
          maskDiv.style.top = '0px';
          maskDiv.style.overflow = "hidden";
          maskDiv.style.zIndex = "9999";
          maskDiv.style.pointerEvents = "none";
          maskDiv.style.opacity = 0.005;
          maskDiv.style.fontSize = '20px';
          maskDiv.style.color = '#000';
          maskDiv.style.textAlign = "center";
          maskDiv.style.width = `${width}px`;
          maskDiv.style.height = `${height}px`;
          maskDiv.style.display = "block";
          document.body.appendChild(maskDiv);
      }

      左侧是从网页上接下来的图片,右侧是在PS工具中处理之后的图片[2],明显可以看到我们设置的水印。

      而生成图片的方式就有很多种了,可以是前端生成,也可以是将信息发给后端,后端生成一张图片,然后前端将图片作为背景图。

      想要得到右侧的结果,未必需要PS进行处理,可以通过其他的方式进行处理。

      到这里,前端部分就结束了,但可能有人还觉得这不太行,我截网页的图现在是加上了水印,但是我要是保存原图呢?那可以用之前说的RGB分量那个方式。

      那我下载图片之后在原图上截取呢,不就失效了?确实,到这里前端能做的工作已经很少了。我们已经处理不到了,但是在图像暗水印,或者说盲水印这个领域,还有更加有效的抵抗攻击(去水印)的方式,比如频域、空域的变换。这个变换可以说是老生常谈的了,我就不过多解释了。

      补充两句

      水印的概念是泛化的,并不是说只有显示在图片某个角落的信息才能被称为水印。

      上面选择将信息追加到文件末尾是有原因的,不是瞎选的。任何一种文件都包含文件结束符,就如文件头部约定存放文件的格式信息一样,即使你改了后缀,我也能通过读取这个文件头部的内容来识别文件真实的格式。

      另外我们知道,文件后缀名是可以随意更改的,如果只通过文件后缀名进行检测,那么绝对是可以绕过的,进而出现任意文件上传的安全问题。

      如果改变图层混合模式没能成功,不妨试下修改图像的RGB曲线

      参考文章


      1. 不能说的秘密——前端也能玩的图片隐写术 | AlloyTeam ↩︎
      2. 阿里巴巴内网的不可见水印用的是什么算法? – Mize的回答 – 知乎 ↩︎

    3. 简单聊两句 XSS

      XSS(跨站脚本攻击),聊两句,五毛的。

      XSS的危害:

      • 窃取Cookie,盗用用户身份信息

      这玩意儿是大多数XSS的目标,也好解决,可以先治个标,直接设置HttpOnly=true ,即不允许客户端脚本访问,设置完成后,通过js去读取cookie,你会发现document.cookie 无法读取到被标识为HttpOnly的Cookie内容了。

      • 配合其他漏洞,如CSRF(跨站请求伪造)

      这个其实就没那么好解决了,因为XSS利用用户身份构造的请求其实对于服务端来说是合法的。比如说咱在B站上传了一条视频,发现没几个人点赞,于是动了歪心思,打开控制台找到了投币点赞的接口,然后拿到了对应的请求参数。自己构造了一条投币请求,然后诱导其他人点击含有这个脚本的页面为咱的视频投币,这样就完成了一套攻击流程。

      不用尝试了,没用的。别问我怎么知道的 =。=。

      要是没做校验的话,那这就是一个高危漏洞,还传啥视频啊,赶紧发邮件给阿B领赏金去啊。

      • 广告

      只要能发起XSS,我就能往页面里插广告,啥权限都不要,但是能引发这个问题的原因主要有两个。

      1. XSS。
      2. 用户自己安装外部脚本。

      使用外部脚本一定要保证脚本来源的可信性,脚本的安全性。如果脚本是恶意的,那么他所能做的可就不只是弹弹广告这么简单了,替换个按钮,诱导点击钓鱼页面,替换某一条搜索结果,这都是可能的。

      XSS扫描及防范

      XSS风险有些是可以通过code review发现的,比如:

      let result = document.getElementById('test');
      result.innerHTML = await getInfo();
      

      这段代码很容易看到风险位置——innerHTML ,如果后端返回的数据中包含恶意的代码片段,那么就能够被攻击。所以在使用Vue和React框架时,需要评估是否真的需要使用v-html 和dangerouslySetInnerHTML 功能,在前端的render(渲染)阶段就避免innerHTML 和 outerHTML [1]

      如果不使用框架,那就避免直接使用innerHTML 就好了。

      至于review时无法发现的风险,那就交给扫描器吧。

      防范XSS,除了少使用、不使用innerHTML 外,还可以设置严格的CSP[2],限制用户的输入长度。

      XSS是一个安全问题,它不只是前端的职责,这也是所有RD和QA的职责。

      前端过滤用户输入后发给后端,后端如果不做处理存入数据库,那么这就是一个攻击点:直接抓前端的包,重新组装一下参数,发给后端,完成存储型XSS第一步,用户再访问这部分内容,就完成了一次XSS。

      QA的总能搞出来一些奇奇怪怪的payload(亦称测试用例),这些可能都是RD未能考虑到的方面。

      附一段白名单过滤用户输入的代码,点击GitHub查看。


      1. 如何防止xss攻击 ↩︎
      2. 内容安全策略 ↩︎
    4. angular浏览器兼容性问题解决方案

      问题:edge浏览器下,固定列的边框消失

      原因:ng-zorro-antd表格组件使用nzLeft和nzRight指令固定的表格列,这两个指令的实现css3中的标签:

      position: -webkit-sticky !important;
      position: sticky !important;
      

      谷歌、火狐及-webkit-内核的浏览器均支持该属性(css3),IE不支持该属性,所以在IE中,会自动降级,表格无固定列,可滑动的形式。 Edge浏览器在1703之后的版本使用了chromium内核,对css3的属性支持较好,也支持sticky属性,可以使用,可以固定表格列,但边框会消失。

      解决方案: 目前可行的解决方案有如下几种:

      1. 不使用固定列,若产品没有明确要求使用固定列,可以放弃使用nzLeft及nzRight来固定表格。从而使各个浏览器下的展示效果一致。

        针对Edge浏览器降级处理,与IE浏览器效果一致,无固定列,整体可横向滚动。

      2. 自定义实现固定列,不使用组件的固定列实现,通过使用position: absolute;这种方式来实现表格的固定列。

      第二个方案的详细过程如下:

      使用div包裹表格,当表格宽度超过div宽度时,开启滚动:

      .scroll-table {
        width: 100%;
        overflow-x: scroll;
      }
      

      针对表格,我们可以指定宽度,让其超过外层div宽度(这样可以看到滚动效果)。

      .fixed-table {
        width: 1300px; /* 可由th,td动态扩充,也可指定宽度 */
        border-collapse: collapse;
      }
      

      最后一个最核心的问题,就是固定列的实现了,非常简单,将表格的一列设置成绝对定位,在设置了绝对定位后,该列会脱离原来的文档流,表格少了一列,所以需要加一个背景板来保证表格能够给这个固定列留出一个位置。

      HTML代码大致如下,这个fixed-col可以为固定列的样式,也可以设置成背景板的样式,demo中是用其指定了固定列的样式。

      <div class="scroll-table">
          <table class="fixed-table">
              <thead>
                  <tr>
                      <th>无效背景板</th>
                      <th class="fixed-col">固定列</th>
                  </tr>
              </thead>
              <tbody>
                  <tr>
                      <td>无效背景板</td>
                      <td class="fixed-col">固定列</td>
                  </tr>
              </tbody>
          </table>
      </div>
      

      参考代码:Ironape

      问题:IE浏览器下,在多个tab页中切换,echart所在容器高度坍塌

      原因:IE浏览器下父元素不能动态调整高度(即通过子元素动态改变调整高度)

      解决方案:固定echart图表所在的容器高度


      问题:IE浏览器下,初始化表单时,触发表单验证

      原因:这个是IE的问题,IE10+实现了input事件,但是触发的时机却是错误的。比如在placeholder改变时,placeholder的文字不是英语的时候就会触发,Edge15+修复了这个问题,但是IE可能永远都不会修复这个问题。

      解决方案

      1. 使用表单的reset()重置表单,但是重置的操作需要放在setTimeout中,或者通过其他手段将重置的操作作为表单初始化时的最后一个宏任务执行。这种方式经验证,最终的效果是,初始化表单后,表单输入元素的边框闪烁(红色)一下。
      2. 使用自定义的服务商插件(较为推荐),这种方式对原有代码的破坏性小(遵循了OCP原则),该插件是由DerSizeS提供的。只需要在对应的module中增加一个服务商即可
      @NgModule({
          providers: [{
              provide: EVENT_MANAGER_PLUGINS, multi: true,
              useClass: UniqueInputEventPlugin, deps: [UNIQUE_INPUT_EVENT_PLUGIN_CONFIG],
          }]    
      })
      class MyModule {}
      

      需要注意的是,插件需要自己添加到项目文件中(根据angular团队所说,这个插件修复了一个IE10或者IE11的bug,但是提交了太多的代码,这会给增加现有的应用的打包体积,虽然后面关于这个PR讨论了挺久,但是看样子是准备把这个放到FAQ里面,而不会把他并入框架),并在对应的模块中引用。

      1. IE的输入框会因为placeholder为中文而触发表单验证,placeholder改变了也会触发表单验证,所以,有一个讨巧的方法,placeholder里面的内容写成英文形式(推荐),但这显然不符合中文产品的需求,而且这显然没有国际化。所以可以想办法绕过这一条,使用 HTML实体(已验证,可行),Unicode编码(不可以)
    5. 想学canvas?那一定要看看这篇文章

      想学canvas?那一定要看看这篇文章

      canvas 简介

      在学习一项新技术之前,先了解这项技术的历史发展及成因会帮助我们更深刻的理解这项技术。

      历史上,canvas 最早是由 Apple Inc. 提出的,在 Mac OS X webkit 中创建控制板组件使用,而在 canvas 称为 HTML 草案及标准之前,我们是通过一些替代方式去绘图的,比如为人所诟病的 Flash,以及非常强大的 SVG(Scalable Vector Graphics,可伸缩的矢量标记图),还有只能在 IE(IE 5.0以上的版本)中使用的 VML(Vector Markup Language,矢量可标记图)。甚至于有些前端可以使用 div+css 来完成绘图。

      总的来说,没有 canvas 的时候,在浏览器绘制图形是比较复杂的,而在 canvas 出现之后,绘制 2D 图形相对变得容易了。

      NOTE: 用 div 绘制一些简单的图形,如矩形,圆形,三角形,梯形,倒也算是没那么复杂。

      但 canvas 也有缺点。因为 canvas 本质上是一个与分辨率相关位图画布 ,也就注定了在不同分辨率下,canvas绘制的内容显示的时候会有所不同。此外,canvas 绘制的内容不属于任何DOM元素 ,在浏览器的元素查看器中也找不到,那自然无法检测鼠标点击了 canvas 中的哪个内容,很显然,这两方面,canvas 都是不如 SVG 的。

      举个例子:如果使用 CSS 设置 canvas 元素的尺寸,那可能会导致绘制出来的图形变得扭曲,如长方形变正方形,圆形变椭圆等,这是因为画布尺寸和元素尺寸是不一样的,画布会自动适应元素的尺寸,如果二者是成比例的,那么画布就会等比例缩放,不会出现扭曲。

      这么说来,canvas 有这么明显的缺点,那直接使用 SVG 岂不是更好?

      No,听过一句话吗?没有完美的方案,只有适不适合。

      SVG 是基于 XML 的,那么就说明,SVG 里面的元素都可以认为是DOM元素 ,可以启用 DOM 操作,同时,SVG 中每个绘制的图像均被视为对象,若 SVG 对象属性变化,浏览器会自动重现图形。

      以上是 SVG 的优势,但通过这个优势,我们也能发现一些问题:

      1. 通常,过度使用 DOM 的应用都会变得很慢,所以,复杂的 SVG 会导致渲染速度变慢。但是像地图这类的应用,首选是 SVG。
      2. 浏览器的重排发生在浏览器窗口发生变化,元素尺寸位置变化,字体变化等等。
      3. 即使可以启用 DOM 操作,但 DOM 操作的代价还是比较昂贵的(DOM 和 JS 的实现是分开的)。

      回到主题。

      canvas 是通过 JavaScript 进行 2D 图形的绘制,而 <canvas> 标签本身是没有任何绘制能力的,它仅仅是一个容器。在绘制时,canvas 是逐像素的进行渲染的,一旦图形绘制完成,该元素就不再被浏览器所关注(脚本执行结束,绘制的图形也不属于DOM)。

      值得注意的是,在 HTML 标准(whatwg 标准)中明确的指出: Authors should not use the canvas element in a document when a more suitable element is available. 所以,不要滥用元素。

      canvas 目前几乎被所有的浏览器支持,但是IE 9.0 之前的版本不支持 canvas元素

      canvas 基本使用

      canvas 是一个HTML元素,所以要使用 canvas,首先需要:

      <canvas id="canvas" width="600" height="300">
        当前浏览器不支持canvas
      </canvas>

      在第一行 HTML 代码中可以看到两个属性:width 和 height ,它指明了画布的宽高,在上文中提到过,不要使用 CSS 规定尺寸,因为当 CSS 规定的尺寸和画布尺寸比例不一致时,无法成比例缩放,导致绘制出来的图形变得扭曲。在没有设置画布大小时,canvas 默认会初始化成 300px * 150px 的画布。

      “当前浏览器不支持 canvas ”是元素的内容,但他只是作为一个后备内容(即 fallback content),只有当浏览器不支持 canvas时,这个内容才会被显示出来。

      canvas元素本身没有绘制能力,只是作为一个容器,所以需要通过JavaScript这类脚本进行绘制:

      const canvas = document.getElementById('canvas');
      const context = canvas.getContext('2d');

      上面的 HTML + JS 代码是使用 canvas 所必须的,无论要绘制什么内容,这几行代码不可缺少。

      getContext() 是 canvas 元素提供的方法,用于获取绘制上下文(或者说渲染上下文,The rendering context),他只有一个参数:上下文格式。这里传入 2d 表示获取 2D 图像绘制环境。由于 getContext 是 canvas 元素提供的方法,故我们可以通过检测 getContext 方法的存在性来检查浏览器的支持性。

      context 变量的类型是 CanvasRenderingContext2D 。

      渲染上下文不好理解,可以理解为画图用的笔刷。

      在画布中如何确定绘制的位置?是坐标。

      在 canvas 中,画布的左上角为原点,横轴为x轴表示宽,纵轴为 y轴表示高[1]。原点的位置是可以移动的,我们暂时不考虑原点的移动问题。

      w3c school 中,将 canvas 提供的绘制 API 大致分为以下几种[2]

      1. 颜色、样式、阴影
      2. 线条样式
      3. 矩形
      4. 路径
      5. 转换
      6. 文本
      7. 图像绘制
      8. 像素操作
      9. 合成
      10. 其他

      在上面这个例子中,包含了矩形,圆形,线,文字及“文字”几大块内容,细讲下去,会涉及到不少 API,会使得本文变得很长,而且没有必要,值得一提的是贝塞尔曲线,这是二维图形应用程序的数学曲线,一般的矢量图形软件就是通过它来精确画出曲线的,贝塞尔曲线是计算机图形学中相当重要的参数曲线[3]

      一次贝塞尔曲线
      二次贝塞尔曲线
      三次贝塞尔曲线

      以上图片按顺序分别是一次贝塞尔曲线,二次贝塞尔曲线,三次贝塞尔曲线。从图中,可以很清楚的看到,一次贝塞尔曲线实际上是一条直线。当然,还有更高阶次的曲线,不过canvas只提供了二次和三次贝塞尔曲线。

      以二次贝塞尔曲线的API为例:

      quadraticCurveTo(cp1x, cp1y, x, y);

      (cp1x, cp1y)表示控制点坐标,(x, y)表示结束点坐标。这里还缺少一个起始点坐标,假设是(x0, y0),那这个(x0, y0)是谁?

      就是在调用 quadraticCurveTo 函数时,context(绘制上下文)所处的坐标。举个例子:

      var cxt = canvas.getContext('2d'); // 认为canvas已经获取到
      cxt.beginPath();
      cxt.moveTo(120, 90);
      cxt.quadraticCurveTo(130, 80, 130, 70);
      cxt.quadraticCurveTo(115, 70, 115, 50);
      cxt.quadraticCurveTo(115, 30, 155, 30);
      cxt.quadraticCurveTo(195, 30, 195, 50);
      cxt.quadraticCurveTo(195, 70, 155, 70);
      cxt.quadraticCurveTo(135, 90, 120, 90);
      cxt.stroke();

      这段代码运行结果就是一个对话框(在第一张图片中体现),可以看到,在调用二次贝塞尔曲线之前,我们设置了起点,即,将笔刷移动到坐标 (120, 90),在之后调用中,都是以前一次贝塞尔曲线的终点作为本次曲线的起点。

      这时候可能会有人问:我去掉这个 moveTo 的调用是不是就画不出来了?如果后续是调用 lineTo函数,那还真就画不出来了。但是别忘了,还有一次贝塞尔曲线,这就是条直线,他是以(cp1x, cp1y)为起点,(x,y) 为终点的一条直线。所以说,去掉 moveTo 后,只会影响到第一条曲线的绘制。但是如果删除最后一行代码 stroke(),那么程序运行结束时,在浏览器上啥都看不到。

      由此,我们应该思考另一个问题:为什么 stroke() 函数是必须的呢?

      其实,canvas 是一种基于状态的绘制,依照此,可以将 canvas 提供的 API 分为两种:状态设置,具体绘制。

      stroke()fill() 等函数就是将内容绘制到 canvas 画布容器中的函数。

      arc()lineTo()rect()等函数就是设置笔刷状态的函数。

      在那种玄幻类型的电影、电视剧里面就经常能看到某个道士虚空画符,画完之后往前一推,就印在了对应的符或者人身上了。

      道士虚空画符,这个过程就像是canvas设置笔刷状态的过程。

      往前一推,这个就是具体的绘制了,怎么绘制咱不知道,反正这符是画上去了。(前文提到过,canvas 是逐像素渲染的)

      “文字”的绘制,注意,这个文字是打了引号的,普通文字,我们绘制只需要调用 fillText() 即可,而这里所指的文字是点阵字体,在单片机或者 LCD 这类程序中,通过点亮一系列的点,显示出文字或图案,点亮的过程较为复杂,可以简单的理解为 LCD 上的像素点置为1时点亮该点,为 0 时不点亮(实际可能相反)。那么 canvas 这里的“文字”绘制也是一样的道理,通过建立文字对应的字体库,当需要绘制某个文字的时候,在字体库中找到对应的文字点阵,然后将点阵中标志为 1 的位置点亮(填充)即可。

      实际操作时,可能并不是点亮这么简单,你可能会想要制作出更酷的内容,用圆形去填充,用矩形去填充,甚至说想要制作出动态爆炸的效果,这时候就牵扯到一些其他的计算了。

      点阵时间

      上图是一个用矩形填充的示例,数字对应8×8的点阵。

      canvas的高级动画

      先思考一个问题,假设现在我们已经学会了绘制一个圆形的方法,现在要求做出一个和物理学相关的动画:平抛运动。

      现在该如何去实现呢?

      可能看到这个问题的时候,有些人瞬间懵圈了:我就学了个绘制圆的函数,你就让我模拟这么高难度的动画,你这分明是想谋害朕!

      可能也有人会想到,平抛运动,在高中物理学中学到过,基本都只是研究一个小球的问题,在 2维平面中,这小球完全可以视作一个圆,可不就只需要学会画圆就行了?

      经此,我们继续往下思考,在平抛运动中的小球,假设水平方向设有初始速度 v0,除了重力外,不受到其他外力影响,也即存在一个重力加速度 g(为了计算简单,我们可以简单的设为g = 10m/s2),同时竖直方向没有初速度 vh(或称 vh = 0;),如下图:

      平抛运动

      从图中,我们可以看到一些很有意思的现象,如:小球的水平方向刚好和 canvas 画布的横轴一致,竖直方向也和纵轴方向保持一致。

      然后由平抛运动对应的物理公式:

      // 竖直方向无初速度,水平方向没有外力
      x = v0 * t; // 水平方向位移
      h = 1/2 * g * t * t; // 竖直方向位移
      
      // 竖直方向有初速度
      h = vh * t - 1/2 * g * t * t; // 竖直方向位移

      发现(x, h)和 canvas 上的坐标(x, y)是一致的,而且我们也不是在做物理题,也就是说,v0, t, g, vh 这些参数都是已知的,我们唯一需要做的就是,计算出任意时刻的(x, h),也即小球在 canvas 上的坐标(x, y)。

      分析结束,我们现在可以得到小球在任意时刻的位置坐标,那么我们也就可以在画布上画出来任意时刻的小球。

      针对上面的分析,可能会有人说:你这不对,你这个应该是具有特殊性的吧,小球未必是从左边抛出去的,从右边也可以啊,向上抛也可以。

      的确,上面的分析只是取出了其中一个比较特殊的状态来研究,限于篇幅(以及本文主题是 canvas 而非物理),没有推广到更一般的结论,但其实,这些分析已经足够了,无论是位移还是速度,他都是矢量,带有方向,那么我们不妨规定:以canvas的坐标轴,数值增加的方向为正向,那么从右边抛出,可以认为是反向,可以表示为 -v0 ,最终通过计算位移的公式,可以得到正确的坐标(但这时候算坐标 x 是比较麻烦的,不能直接使用上述公式)。

      分析这么多,说点儿咱最关心的实现。

      在之前的分析中,我们知道想求小球任意时刻所在位置坐标,需要的参数有:v0, t, g, vh。这些参数应该存放在哪里呢?怎么设计这个数据结构?

      我们当然可以直接将这些参数设为全局变量,但这显然是不合适的,这些参数里,唯一适合设为全局变量的是重力加速度 g。而v0, t, vh这些都应该是小球自身的“属性”,所以我们应该将其抽象成一个类。

      function Ball(r, v0, vh, t) {
          this.r = r;
          this.v0 = v0;
          this.vh = vh;
          this.t = t;
          this.x = 0;
          this.h = 0;
          
          this.calcX = function() { /* 计算水平位移 */ }
          this.calcH = function() { /* 计算竖直位移 */ }
      }
      
      var ball = { x: 0, h: 0, r: 10, v0: 0, vh: 0, g: 10};
      // 重力加速度无论是作为全局变量还是小球属性,均可
      
      // es6之后
      class Ball {
          constructor();
      }
      

      以上三种方式,各有各的好处,选择一个合适的方式即可。

      “你这说物理我就头大,有没有更简单的?”

      更简单也有啊,反正并没有要求 100% 还原物理学场景:

      var ball = { x: 0, y: 0, r: 10, vx: 5, vy: 0, g: 5 };
      setInterval(() => {
          ball.vy += ball.g; // 竖直方向速度增加
          ball.y += ball.vy; // 竖直方向位移
          ball.x += ball.vx; // 水平方向位移
          cxt.clearRect(0, 0, 800, 300);
          cxt.beginPath();
          cxt.fillStyle = 'black';
          cxt.arc(ball.x, ball.y, ball.r, 0, 2*Math.PI);
          cxt.fill();
      }, 50);
      

      OK,结束了。

      这就是高级一点的动画。可能在学几个函数,这个动画会更炫一点。比如学完矩形填充再掌握一点rgba的知识,你可以做个“尾巴”出来,即长尾效应。具体只需要将上述代码中的 cxt.clearRect() 替换成:

      cxt.fillStyle = 'rgba(255, 255, 255, 0.2)';
      cxt.fillRect(0, 0, 800, 300);
      

      这就能显得咱们编码能力很厉害的样子。

      做到这一步还是不满足:小球一个劲儿的向下掉,这动画没一会儿就没了。

      没关系,咱们可以做“碰撞检测”啊。好像又是一个高大上的词汇,但实际上也没什么高大上的,如果基于本节第一部分的分析,那咱还得考虑一下碰撞造成的动量损失的问题,挺复杂的。

      但是简化版就好说了啊。小球碰到上/下边界,竖直方向速度反向,同时速率减半。左右边界可以有类似的处理。

      if (ball.r + ball.x > canvas_width) {
          ball.vx *= -0.5
      }
      if (ball.r + ball.y > canvas_height) {
          ball.vy *= -0.5;
      }
      

      NOTE:碰撞检测在这里指的是“边界检测”,小球落到边界的时候再继续下落显然是没有意义的,因为后面的动画咱们是看不到的。所以要么碰到边界就停止,要么重新开始,或者进行其他处理,总之,不能出现无意义的动画。

      像以前玩的贪吃蛇,会有各种墙的存在,控制的小蛇在碰到墙的时候,游戏就失败了,或者说没有墙的时候,小蛇会从另一个方向出来。

      小结

      说了这么多,你会发现,本文不仅没有直接的罗列不同的 DEMO 来介绍函数,更是在尽量避免过多的介绍 canvas 中的API。

      个人看来,canvas 其实就是一个函数库,他和我们平时使用的那些什么 forEach,splice,split,map,reduce 没什么区别,都是封装好了直接用的,查一查函数手册就可以了解用法了,多用几次就会比较熟悉了。

      刚进大学的时候,专业课老师就告诉我们,程序=算法+数据结构,即使到现在,也有很多人在强调这一点。如果你有心,再回想一下上一节内容,在分析平抛运动的时候,我本质上是在考虑算法问题;在设计小球的类时,考虑了面向对象,但更多的是在考虑数据结构的问题,在考虑了这些内容的基础上,我才开始了具体的实现。


      参考资料:

      1. MDN文档 ↩︎
      2. HTML 5 Canvas参考手册 ↩︎
      3. 贝塞尔曲线 ↩︎
    6. 前端缓存技术概述

      前端缓存技术概述

      缓存概述

      在计算机领域中,缓存是一项十分重要的技术。

      在软件开发,亦或者是在硬件设计开发中,缓存对性能的影响是十分显著的。

      学过Java,会知道在Integer的自动装箱中 [-128, 127] 这个范围中的转换会有些特殊的表现,稍加研究源码,会知道这是因为 Integer 中的缓存类有关(该缓存类会使用数组存储 [-128, 127] 范围内的常量)。当然,在实际开发中,可能存在 Redis 缓存,框架缓存等。

      再有,cpu cache可能是最常听到的一种硬件缓存机制了。对于计算机专业的同学来说,cpu cache可能了解的更多些,大致如图,这里就不多表述了:

      前端方向的缓存包括:浏览器缓存,HTTP 缓存,DNS 缓存,CDN 缓存等。

      有人会说,浏览器缓存和前端肯定是有关系的,但是 HTTP 不是个协议吗,这个怎么缓存?DNS 我倒是知道,这玩意儿是请求解析 URL 对应的 IP 地址的,这个跟前端又有啥关系?而且这 CDN……

      前端缓存

      提到前端缓存,很多人立刻能想到的是浏览器缓存,然后又把浏览器缓存划分两种:1. 强缓存,2. 协商缓存。如果再向下深入,就按下F12打开控制台切换到 Network 选项卡,然后指着那些请求的 Header 的每一个字段说:你看,这个 Cache-Control 和 Expires 是用来控制强缓存的,这个 Last-Modified,ETag 还有这些 If-xxx-xxx 都是用于协商缓存的。

      再继续往下进行,就有人要说了:其实,这里还要划分缓存策略,浏览器按照发生的时间顺序划分策略,1. 存储策略,2. 过期策略,3. 协商策略,每个字段对应有不同的缓存策略,存在一个字段对应多个策略的情况。

      到这里,就有人意识到了,这说了半天本质上都在讲根据 HTTP 协议制定的缓存机制,也就是常说的 HTTP 缓存,也是一种浏览器缓存。

      那么,其他的呢?

      客户端缓存

      通常,客户端缓存指的是浏览器缓存,更具体一点,就是本节开头所提到的 HTTP 缓存。

      DNS 缓存

      DNS 是一个协议,用于域名 URL 到 IP 地址的映射,或者说是根据 URL 去查询 IP 地址。

      在 Windows 下,我们可以在 C:\window\system32\drivers\etc\ 目录下找到一个 hosts 文件,这个文件记录了 URL 到 IP 的转换,通常来说,我们不需要手动去修改这个文件,而是在网络协议中配置 DNS 服务器,通过 DNS 查询完成 URL 到 IP 的转换。

      现在 hosts 还在继续使用,一般内网中会要求员工手动配置 hosts 文件,或者通过运行脚本进行 hosts 的写入,这样配置完成之后才可以访问某些内部网络。

      简单来说,DNS 协议的实现就是:客户端请求服务器得到结果,这个结果就是 IP 地址。

      假设 DNS 解析请求发出到收到结果的时间是 100ms,不加 DNS 缓存:

      浏览器访问网站需要查询 URL 对应的 IP 地址,而网站的接口也需要进行查询 IP,首先浏览器在 DNS 请求过程中等待,什么都不做,这时候浏览器只能保持白屏 100ms,而在后续的接口请求中,我们还要进行 DNS 查询,这样的等待是没有意义的,而且这对 DNS 服务器带来的压力也不小……

      但是在加了 DNS 缓存后,我们就可以直接在缓存中找到 URL 对应的 IP,省去了等待时间,响应速度一下就上去了。

      通常,DNS 查询的时间在 20ms 左右。

      现在的浏览器都实现了 DNS 缓存,不过采用的方式是不同的,至于具体采用了啥方式,这个我也不知道了,不过有一点,IE 设置了30分钟的缓存时间,Chrome 和 FireFox 则是设置了1分钟的 DNS 缓存有效期。但无论是30分钟还是1分钟,时间长短并不是区分其优劣性的因素。 时间设置的短:那么浏览器就对IP变化敏感,可以保证请求是正确的。 而时间设置的长了:那么就可以避免重复请求 DNS 服务器,节省时间。

      NOTE:还有一个协议需要了解,就是 ARP 协议(地址解析协议),我们在得到了 IP 之后,需要进行 TCP 握手,这一步需要找到 MAC 地址,而 ARP 协议的作用就是 IP 到 MAC 的映射。

      HTTP 缓存、浏览器缓存

      HTTP 缓存就是老生常谈的东西了,HTTP 本身只是一个协议,HTTP 缓存则是浏览器实现的,在本文开篇就已经提到了,后文统称 HTTP 缓存。

      浏览器通过设置或者读取 HTTP 头来实现对应的缓存机制:

      1. 强缓存

      当请求命中强缓存时,浏览器不会将本次请求发往服务器,而是直接从缓存中读取内容,在 Chrome 中打开控制台,切换到 Network 选项卡,可以看到一个比较不一样的状态码信息 200 OK (from disk cache),如下图:

      200 OK (from disk cache)
      颜色不同的200 OK

      控制强缓存的字段主要是 Cache-Control 和 Expires(HTTP 1.0标准)。

      Cache-Control:常见的值有 public,private,max-age,no-cache,no-store,must-revalidation;少见,但是用的也多的值有 max-stale,min-fresh。

      max-age,max-stale,min-fresh 这三个值同时使用时,其设置独立生效,但是最保守的缓存策略总是有效(所谓最保守,你可以认为,缓存时间最短的总是有效)。

      此外,no-cache 字面意思是不缓存,但实际上,浏览器读到这个值之后,依旧会将资源缓存,在下次请求时检查资源是否有效,如果有效,那服务器就返回 304 状态码,浏览器读缓存;否则浏览器向服务器请求更新的资源。这与 Cache-Control: max-age, must-validation 效果相同。

      以上各个字段的意义均可在 MDN文档 中查询到,这里不再赘述。

      部分HTTP Header字段

      Expires:资源到期时间,这个时间是服务器的时间,所以这里就会出现一个问题,服务器时间和本地时间不一致。但问题不大,只是这样本地强缓存会失效而已……等等,本地时间和服务器时间不一致并不一定是本地时间超出了指定的到期时间,也有可能是本地时间被修改至到期时间之前,那么这不就使得本地缓存有效了吗?那不就可能存在本地缓存和服务器资源不一致的问题了?

      NOTECache-Control:max-age 的优先级要比Expires高

      如果某天看到 HTTP 请求头中不包含这两个字段同时也不存在其他缓存设置,是不是就用不到缓存了呢?当然不是,浏览器自身会实现一个启发式缓存算法,通常是取出响应头中 Date 和 Last-Modified 两个字段,计算其差值的 10% 作为缓存时间:(Date – Last-Modified) * 10%。

      1. 协商缓存

      根据字面意思,就是前后端进行协商,校验缓存的有效性。

      相对来说,这一策略就有很多字段能控制了,不过也很好记,基本都是 If-Xxx-Xxx 这种形式的,而If前缀则表示这个字段是用于判断(验证)的。

      ETag:实体标签,服务器资源的唯一标识符,有点像哈希值。 Nginx 官方采用的计算方式是“文件最后修改时间的16进制-文件长度的16进制”。 配合 If-Match 和 If-None-Match 使用,验证缓存的有效性。

      通过 If-Match 配合 Range,我们还可以实现文件的断点续传、文件分段下载、并行下载这些听上去挺高大上的功能,原理很简单,就是请求头通过 Range:bytes 请求资源的某一部分,而 If-Match:ETag 可以保证新新范围的请求和前一个请求来自相同的源(ETag 不一致,那就说明资源不一致咯)。

      Last-Modified:标记请求资源的最后一次修改时间,GMT 时间(格林尼治标准时间),由此可知,该字段可以精确到秒级。此外,该字段记录资源最后的修改时间,但是并不会验证资源内容是否真的发生了变化(资源编译打包就会改变该字段的值)。配合 If-Modified-Since 和 If-Unmodified-Since 校验缓存的有效性。

      这个 Last-Modified 字段除了验证本地缓存资源的有效性之外,倒还可以用于当前请求的服务器文档是否被修改,比如说石墨文档,腾讯文档等共享文档之类的,当然了,这些软件都有很多的其他机制保证编辑修改操作能够正确的进行下去。通过 If-Unmodified-Since:Last-ModifiedIf-Range:Etag | Date 再搭配上 Range:bytes (没有 Range 字段,If-Range 字段就会被忽略)可以保证只有服务器上的文档在没有修改的情况下执行更新,实现一个粗糙的文档协作。

      NOTELast-Modified字段优先级比 Etag 低。

      到这里,我们可以知道缓存的优先级为:

      强缓存>协商缓存, Cache-Control > Expires > ETag > Last-Modified

      多数人不对 HTTP 缓存和浏览器缓存区分的,或者说直接合在一起称为“浏览器 HTTP 缓存”,还有人直接称之为“前端缓存”,其实都在说同一个内容。

      服务器缓存

      提到服务器,一般来说都和后端是相关的,但是前端也必须要了解一些相关的知识,因为每次出现问题的时候,都是会在前端页面上显示出来,比如说接口 500,这时候,测试就来到了我们身边,俯下身子在我们耳边吟唱死亡颂歌……

      当然了,上面说的有些夸大,因为多数时候遇到 5xx 的问题,测试会直接找到后端排查,若是遇到参数传递的问题,后端才会报告给前端去解决。

      CDN 缓存

      DNS 缓存中提到过为了得到 IP 地址,需要进行一次 DNS 查询。

      在这里,DNS 请求获取到的 IP 地址是服务器的 IP 地址,对于同一台服务器来说,接收处理的请求越来越多,那么服务器的负载也就越大,服务器对请求的响应可能就会超时。此外,不同地区访问网站的时延是不同的,若服务器在北京,用户在新疆或西藏地区,那么这个访问时延会非常大,用户等待的时间也就越长。

      CDN 全称是Content Distribute Network,即内容分发网络。在使用了 CDN 的情况下,上述的两种情况都能得到很好的解决。

      CDN 理论大致可表述如下:通过在 不同地区 建立 多个 节点服务器,使得用户的请求可以根据其所在地区、当前网络流量、服务器负载、网络连接等因素,被导向最佳的服务器节点。

      其适用场景较为广泛,如站点加速,直播,点播等。

      讲到这里,就应该对 CDN 有个模糊的概念:这以前没有 CDN 的时候,直接请求源站,现在有了CDN,那么请求肯定会被转发到其他服务器,而且这个服务器中的资源可能是一个源站资源的拷贝。咱们可以称这个服务器为 CDN 节点

      CDN 缓存是指,存在一个缓存服务器,当浏览器向服务器请求资源时,并不是直接向源站服务器请求,而是被导向 CDN 边缘节点。在这个边缘节点中缓存了用户的数据以及源站服务器资源,他(边缘 cache )负责直接响应最终用户的访问请求,将缓存在本地的内容迅速提供给用户。同时,既然缓存了源站服务器的资源,那么就会涉及到资源的一致性,即保证边缘节点与源站服务器内容同步。

      说得简单点,CDN 就是一个房产中介,他根据用户的诉求和他掌握的一些信息(如工作地点,交通情况,距离等)为用户提供一个合适的房子。

      CDN系统在功能上可划分为三大模块:分发服务,负载均衡,运营管理。本节所提到的CDN缓存其实只是从属于分发服务的一个小块。本节内容所说的内容只是一个小的范围-CDN分发服务系统中的边缘cache,并不是指代整个CDN系统。

      总结

      其实还有一种缓存没有提到,这种缓存是用来做离线页面的,而且是直接在HTML中主动使用的——Manifest,使用起来十分简单,只需要一行代码:

      <!—- 写在html标签中 —->
      <html manifest=“/app.appcache”>
      
      <!—- 或者写在meta标签中 —->
      <meta manifest=“/app.appcache”>

      但是很多网站并没有使用这种技术,原因在于这个配置的文件上:

      1. 如果我们想要缓存页面的所有资源,只能手动将资源写入 Manifest 声明的配置文件中进行缓存,而不能使用通配符缓存,这太麻烦了。
      2. 好不容易把 Manifest 配置文件写好了,浏览器这边访问起来也很快,这时候,网站的资源更新了,这个缓存会失效吗?不会的,如果不清除缓存,即使连上网络,资源也不会自动更新,浏览器加载的页面还是旧的页面。我们需要改变配置文件的名字,然后,才会更新本地缓存,通常会增加版本号或者使用 hash 的命名方式。
      3. 解决了这些问题,你又会发现,Manifest 在不同设备,不同浏览器上可能存在大小限制。

      综上,Manifest 的使用还是比较麻烦的,而且用处看上去不大,但是对于一些静态网页, Manifest 就显得比较有用了。

      此外,还有一些没有提到的缓存技术,如代理服务器缓存,反代理服务器缓存等。

    7. 《你不知道的JavaScript》笔记(一)

      《你不知道的JavaScript》笔记(一)

      用了一个星期把《你不知道的JavaScript》看完了,但是留下了很多疑惑,于是又带着这些疑惑回头看JavaScript的内容,略有所获。

      第二遍阅读这本书,希望自己能够有更为深刻的理解。

      词法作用域

      ……如果是 **有状态** 的解析过程,还会赋予单词语义……

      这本书是以编译原理的部份内容结合 JavaScript 来开篇的,所以如果没有学过编译原理,这一小部分内容显得有些晦涩。

      虽然多数人没有接触过编译原理,但有一个东西必定知道,就是 markdown 语法。实际上,从 markdown 文件到 HTML 的过程就包含了词法化的过程。

      那什么是有状态?粗略来讲,就是一个模式匹配的问题,或者可以认为是字符串的匹配:

      源串:"str782yui",待匹配的串:"sj1" ,思考一下朴素匹配的算法,我们是要从头开始比较的,那么在每一次比较的时候,就两种状态,字符相同/不相同,每接受一个字符,就会走向其中一个状态。

      朴素模式匹配

      这就是所谓的状态。当然了,这也只是一种粗浅的比喻,可能会有更好的。

      说句心里话,编译原理是很有用的科目,但是真正学起来的时候还是挺痛苦的。

      考虑以下代码:

      function foo(a) {
        var b = a * 2;
        function bar(c) {
            console.log(a, b, c);
        }
        bar(b*3);
      }
      foo(2);

      在这个例子中有三个逐级嵌套的作用域……

      对于 java 程序员来说,一对花括号就可以限制变量的作用域,而且作用域之间的关系有 同级父子级 两种,同时还有包(package)这种很方便的东西。

      但是 JavaScript 就不一样了,一对花括号是不能定义一个作用域的,而且由于 var 声明的变量存在变量提升,所以有些时候我们会发现某个变量并不能像预期的那样被约束在某对花括号中,于是就出现很多经典的问题。

      JavaScript 中函数和 catch 子块是能够创建作用域的,但是个人认为,为了创建一个作用域而使用 catch 子块,这等于是给catch增加了一个语义,二义性不好说,这可能会使程序变得不好阅读。但大家都这么做的话,那我也就随大流吧。

      想起一个不知道从哪里传出来的笑话,说:catch不是异常处理关键字,而是流程控制语句。

      提到了作用域,就一定要提变量的屏蔽,简单来说就一句话: 内部作用域的变量会屏蔽外部作用域的同名变量

      那可能就有人问了,(上述代码)我通过 foo.b 这种方式能不能访问呢?

      ……想啥呢?想对象了是吧?不过可以通过`window.foo`这种方式来访问全局变量foo,为啥?这`window`不是一个全局的对象嘛,所有的全局变量都会自动的成为全局对象的属性。

      ……词法作用域查找 只会 查找一级标识符……

      这句话就是在回答上面我提出来的问题。

      function foo(str, a) {
        eval(str);
        console.log(a, b);
      }
      var b = 2;
      foo("var b = 3", 1);

      eval(..)调用中的”var b = 3;”这段diamante会被当做本来就在哪里一样处理……

      这话说的好拗口啊?

      有时候写程序的时候,我就会想,哎呀,要是能把一个字符串变成一个变量就好了,多方便啊。

      eval差不多就是在完成这件事。书上说的这么绕,可能是为了可以让读者更好的理解。

      在我看来,eval函数所做的就是动态的生成一段代码,插入到对应的位置上,变成了另一个程序,照此执行。

      with声明实际上是根据你传递给它的对象凭空创建了一个 全新的词法作用域

      到这里又增加了一个能够创建作用域的关键字。

      var obj = {
          a: 1
      };
      with(obj) {
          a = 2;
          b = 3;
      };

      对上述代码,我们可以这么理解,with 将 obj 声明为一个作用域,with 内部的语句都是在这个作用域中的,so……

      eval(..)和with会在运行时修改或创建新的作用域,以此来欺骗其他书写时定义的词法作用域。

      eval 是修改,with 是创建作用域。

      这两个都会导致程序性能的下降,原因是影响了编译优化,其实这俩就像是一个开关,任意一个存在时,都会打开禁止编译优化的按钮。就像是所谓的禁止指令重排一样。

      总结

      |ू・ω・` ):这本书真好看。

    8. TypeScript中使用getElementXXX()

      如果只是看解决方法,可以直接跳到第二小节

      简述

      Angular 1.x 版本是用 JavaScript 编写的,我们在百度 Angular 经常会搜索到 AngularJS,并不是 JavaScript 的什么衍生版本,就是 Angular 1.x。在后续版本中,改用 TypeScript 来重写了 Angular 框架。改动较大,所以做了个区分,Angular v1.x 就叫AngularJS,v2 及后续版本统称为 Angular。

      查资料和解决方案的时候,经常会搜索到大量的AngularJS内容,注意区分。

      在这里提一下 Angular 的历史,是因为本文是在使用这个框架的时候遇到的,所以啰嗦两句。

      问题来了

      现在有如下html文件:

      <!-- 这俩随便挑一个用就行 -->
      <input type="text" id="infoInput" name="infoInput">
      <textarea id="infoArea" name="infoArea" rows="8" cols="80"></textarea>
      
      <!-- 这俩也随便挑一个用就行 -->
      <span id="some">something happen!</span>
      <p id="any">anything ok!</p>
      

      我现在要通过 TypeScript 获取上面任意一个 DOM 元素,怎么做?有 JS 基础都知道,操作 DOM 可以通过 document 完成:

      // 由于DOM元素的ID是惟一的,所以这种方式获取的是唯一的DOM元素
      dom = document.getElementById('infoInput');
      
      // name属性是不唯一的,所以这种方式获取的是所有 name=infoInput 的DOM元素,即一个数组
      dom1 = document.getElementsByName('infoInput');
      

      而在 TypeScript 中当然也可以这么做,但是在具体使用的时候除了需要声明变量保存获取到的 DOM 元素之外,还有一点小小的问题。

      // Angular框架中
      export class Some implements OnInit {
        ngOnInit() {
          let dom = document.getElementById('infoArea');
          // 1. 获取输入框中的内容
          let html = dom.innerHTML;
          let val = dom.value;
      
          // 2. 打印输出
          console.log(html);
          console.log(val);
        }
      }
      

      这段代码写完会报一个错:

      Property ‘value’ does not exist on type ‘HTMLElement’ 不要紧,即使有错误提示,我们依旧可以运行并得到正确的结果。如果想在 ts 文件编译失败时不生成 js 文件,可以通过配置实现。

      HTMLElement 是什么?这是一个对象,它包含了所有 HTML 元素公有的属性。

      关于HTMLElement的详细内容以及浏览器的兼容,可以查看 MDN的这篇文章

      来看一张图:

      图源自 nanaistaken 的博客

      如果你恰好有一点面向对象编程的知识,那么这张图就很容易理解,没有也没关系,毕竟无论是 js 还是 ts,现在都增加了 class 关键字,引入了类的思想。

      经过上面的分析,我们能够知道:getElementXXX() 返回的是一个 HTMLElement 对象,而这个对象包含了所有 DOM 元素的公有属性。而每种不同类别的 DOM 元素,又有自己的特性,也就是图中的子类。

      ts 会做编译检查,所以会有错误提示,而 js 则不检查,所以这也会留下安全隐患。

      到这里,其实应该已经明白了现在这种情况该怎么解决以及以后该怎么使用 getElementXXX 函数了。

      修改后的代码:

      export class Some implements OnInit {
        ngOnInit() {
          // *. 做一次类型转换,或者做类型断言
          let dom = <HTMLInputElement>document.getElementById('infoArea');
          let dom1 = document.getElementById('infoArea') as HTMLElement;
      
          // 1. 获取输入框中的内容
          let html = dom.innerHTML;
          let val = dom.value;
      
          // 2. 打印输出
          console.log(html);
          console.log(val);
        }
      }
      

      总结

      HTMLElement 是 DOM 结点共有的属性,TypeScript 库中抽取该属性作为一个公共接口,类似于其他面向对象语言如 Java 和 c++ 中所说的基类。这样做可以保证在操作 DOM 结点的时候不会出现访问不存在属性的问题。

      HTMLInputElement 是 HTMLElement 的一个子接口(或说子类,但 TypeScript 是支持 class 的,所以说接口更好一些),其内部封装了如 input,textarea 这类 dom 结点的属性。