2020-06 前端技术汇总

2020/06/30 周二

#为什么有效的URI不能包含空格等其他字符,URI编码方法详解

在JS高程3里介绍URI编码方法时,有这样一个描述:有效的URI中不能包含某些字符,比如空格。使用URI编码方法可以把所有无效的字符替换为特殊的utf-8编码,从而让浏览器能够接受和理解

#为什么有效的URI不能包含空格等其他字符?

在《HTTP权威指南》第2章URL与资源 - 各种令人头疼的字符(p38)里有介绍原因:

URL是可移植的(portable),它要统一的命名网上所有的资源,意味着要通过各种不同的协议来传送这些资源,这些协议在传输数据时都会使用不同的机制,在设计URL时,需要满足以下特性:

  1. 可以通过任意协议进行安全的传输:完整/不丢失信息,以SMTP电子邮件的简单邮件传输协议为例,它使用的传输方法会剥去一些特定字符,为了避开这些问题,URL只能使用一些相对较小的、通用的安全字母表中的字符。
  2. URL具有可读性:即使是看不见,不可打印的字符也能在URL中使用,比如空格
  3. 完整性:人们可能会希望URL中可以包含除通用安全字母表之外的二进制数据或字符,因此需要一种转义机制,能够将不安全的字符编码转为安全字符再进行传输

#js中两种URI编码方法的区别

之前有简单说明encodeURIComponent与encodeURI的区别:encodeURI只转义空格,encodeURIComponent会转义所有的非字母数字字符

其实上面的说法是错误的,在mdn的一个示例中,有更加详细说明两者的区别

// encodeURI vs encodeURIComponent
// encodeURI() differs from encodeURIComponent() as follows:
var set1 = ";,/?:@&=+$#"; // Reserved Characters
var set2 = "-_.!~*'()";   // Unreserved Marks
var set3 = "ABC abc 123"; // Alphanumeric Characters + Space  [ˌælfənjuːˈmerɪk] 字母数字 + 空格

console.log(encodeURI(set1)); // ;,/?:@&=+$#
console.log(encodeURI(set2)); // -_.!~*'()
console.log(encodeURI(set3)); // ABC%20abc%20123 (the space gets encoded as %20)

console.log(encodeURIComponent(set1)); // %3B%2C%2F%3F%3A%40%26%3D%2B%24%23
console.log(encodeURIComponent(set2)); // -_.!~*'()
console.log(encodeURIComponent(set3)); // ABC%20abc%20123 (the space gets encoded as %20)

上面的例子中可以看到,URI编码方法一般有3种类型的字符

字符类型(中) 字符类型(英) 包含 encodeURI encodeURIComponent
URI保留字符 Reserved Characters ";,/?&=+$#" 不转义 转义(escaped)
非转义字符 Unreserved Marks or Alphanumeric Characters "-_.!~*'()"以及数字(0-9)、字母(a-zA-Z) 不转义 不转义
空格等其他字符/中文等 space or other character etc " 中文?"等其他字符 转义 转义

通过上面的表格我们可以看到

  • encodeURI不会编码URI保留字符/非转义字符,只会对空格等其他字符(如中文字符,中文等)进行编码
  • encodeURIComponent不会编码非转义字符,URI保留字符和其他字符都会编码

综上,encodeURI与encodeURIComponent的区别是:encodeURIComponent会对URI保留字符进行编码,而encodeURI则不会,其他逻辑基本一致

#标准

下面来看看ECMA262标准/规范(specification)对encodeURI()的定义(definition)

18.2.6.4 encodeURI ( uri )
The encodeURI function computes a new version of a UTF-16 encoded (6.1.4) URI in which each instance of certain code points is replaced by one, two, three, or four escape sequences representing the UTF-8 encoding of the code points.

The encodeURI function is the %encodeURI% intrinsic object. When the encodeURI function is called with one argument uri, the following steps are taken:

  1. Let uriString be ? ToString(uri).
  2. Let unescapedURISet be a String containing one instance of each code unit valid in uriReserved and uriUnescaped  
     plus "#".
  3. Return ? Encode(uriString, unescapedURISet).

NOTE
The code point # is not encoded to an escape sequence even though it is not a reserved or unescaped URI code point.

Runtime Semantics: Encode(string, unescapedSet)

The abstract operation Encode takes arguments string (a String) and unescapedSet (a String). It performs URI encoding and escaping. It performs the following steps when called:

1. Let strLen be the number of code units in string.
2. Let R be the empty String.
3. Let k be 0.
4. Repeat,
     a. If k equals strLen, return R.
     b. Let C be the code unit at index k within string.
     c. If C is in unescapedSet, then
          i. Set k to k + 1.
          ii. Set R to the string-concatenation of the previous value of R and C.
     d. Else,
          i. Let cp be ! CodePointAt(string, k).
          ii. If cp.[[IsUnpairedSurrogate]] is true, throw a URIError exception.
          iii. Set k to k + cp.[[CodeUnitCount]].
          iv. Let Octets be the List of octets resulting by applying the UTF-8 transformation to cp.[[CodePoint]].
          v. For each element octet of Octets in List order, do
               1. Set R to the string-concatenation of:
                   - the previous value of R
                   - "%"
                   - the String representation of octet, formatted as a two-digit uppercase hexadecimal number, padded to the left with a zero if necessary

以上,在总结某个知识点的过程中,我们可以发现介绍js相关知识点的平台很多,我们可以大致的判断出文档的详细程度如下:

菜鸟教程/w3school等 <= JS高程3等技术类书籍 <= MDN <= tc39 ECMA标准文档

js相关知识基本都是对API、tc39 ECMA标准文档的总结、梳理。区别在于:

  1. 整理的意图:是仅仅展示API调用,还是介绍原理(简单说明/详细介绍)
  2. 示例demo是否丰富?demo是否可以让人很好的理解知识点?

参考

  • URI Handling Functions | ECMA-262 Specifications(opens new window)
  • encodeURI() | MDN(opens new window)
  • encodeURIComponent与encodeURI的区别(opens new window)
  • js高程3的引用类型 - 单体内置对象 - global对象 - URI编码方法笔记(opens new window)
  • 关于URL编码 | 阮一峰的网络日志(opens new window)

#2020/06/28 周天

#输入法组合文字事件compositionstart等不能用on监听

今天用原生的js来写demo时发现使用oncompositoinstart无法监听到输入法组合文件的过程,后面替换为addEventListener就可以了。因此对于输入法组合文字过程事件必须要使用DOM2级事件监听


  
  


#2020/06/26 周五

#为什么vconsole直接new一下就能引入,而且可以显示网络请求,console信息

在移动端真机调试时,一般会用到vconsole,那你会发现在vue中vconsole的引入非常简单,只需要在main.js里面引入,并new一下。相比其他组件需要Vue.use引入来说,会很迷惑,这个是怎么引入到项目的?页面底部时怎么显示vconsole的dom的?

// main.js
import VConsole from "vconsole";
new VConsole();

下面我们看看vconsole的源码,看看到底是怎么实现的:

入口在 src/core/core.js,有一个VConsole的class,可以看到这里用了单例模式,只允许有一个vconsole

// src/core/core.js
// ...
class VConsole {

  constructor(opt) {
    if (!!$.one(VCONSOLE_ID)) {
      console.debug('vConsole is already exists.');
      return;
    }
    // ....
// ...

Log、System、Network、Element、Storage都是单独的模块

// src/core/core.js
// ...
// built-in plugins
import VConsolePlugin from '../lib/plugin.js';
import VConsoleLogPlugin from '../log/log.js';
import VConsoleDefaultPlugin from '../log/default.js';
import VConsoleSystemPlugin from '../log/system.js';
import VConsoleNetworkPlugin from '../network/network.js';
import VConsoleElementPlugin from '../element/element.js';
import VConsoleStoragePlugin from '../storage/storage.js';
// ...

constructor 里面挂载dom的方法如下,直接监听window的事件,当确定页面加载ok后,再将vconsole相关dom挂载上去

// src/core/core.js
// ...
// try to init
let _onload = function() {
  if (that.isInited) {
    return;
  }
  that._render();
  that._mockTap();
  that._bindEvent();
  that._autoRun();
};
if (document !== undefined) {
  if (document.readyState === 'loading') {
    $.bind(window, 'DOMContentLoaded', _onload);
  } else {
    _onload();
  }
} else {
  // if document does not exist, wait for it
  let _timer;
  let _pollingDocument = function() {
    if (!!document && document.readyState == 'complete') {
      _timer && clearTimeout(_timer);
      _onload();
    } else {
      _timer = setTimeout(_pollingDocument, 1);
    }
  };
  _timer = setTimeout(_pollingDocument, 1);
}

下面看看console是怎么拦截的,通过下面的代码我们可以看到重写了window.console.log/info/warn等方法,改为执行自己的printLog方法,用于记录log,并渲染到vconsole面板里

// src/log/log.js
// ...
/**
 * replace window.console with vConsole method
 * @private
 */
mockConsole() {
  const that = this;
  const methodList = ['log', 'info', 'warn', 'debug', 'error'];

  if (!window.console) {
    window.console = {};
  } else {
    methodList.map(function (method) {
      that.console[method] = window.console[method];
    });
    that.console.time = window.console.time;
    that.console.timeEnd = window.console.timeEnd;
    that.console.clear = window.console.clear;
  }

  methodList.map(method => {
    window.console[method] = (...args) => {
      this.printLog({
        logType: method,
        logs: args,
      });
    };
  });

当vconsole remove后,还原现场

// src/log/log.js
// ...
/**
 * before remove
 * @public
 */
onRemove() {
  window.console.log = this.console.log;
  window.console.info = this.console.info;
  window.console.warn = this.console.warn;
  window.console.debug = this.console.debug;
  window.console.error = this.console.error;
  window.console.time = this.console.time;
  window.console.timeEnd = this.console.timeEnd;
  window.console.clear = this.console.clear;
  this.console = {};

  const idx = ADDED_LOG_TAB_ID.indexOf(this.id);
  if (idx > -1) {
    ADDED_LOG_TAB_ID.splice(idx, 1);
  }
}

我们再来看看Network相关,也是重写了原生的window.XMLHttpRequest.prototype.open/send等方法,在里面加入了拦截逻辑

// src/network/network.js
/**
 * mock ajax request
 * @private
 */
mockAjax() {
  let _XMLHttpRequest = window.XMLHttpRequest;
  if (!_XMLHttpRequest) { return; }

  let that = this;
  let _open = window.XMLHttpRequest.prototype.open,
      _send = window.XMLHttpRequest.prototype.send;
  that._open = _open;
  that._send = _send;

  // mock open()
  window.XMLHttpRequest.prototype.open = function() {
    let XMLReq = this;
    let args = [].slice.call(arguments),
  // ...
  // mock send()
  window.XMLHttpRequest.prototype.send = function() {
    let XMLReq = this;
    let args = [].slice.call(arguments),
        data = args[0];

综上,我们大致知道vconsole的套路了

  1. 通过window监听页面加载,加载ok后向页面追加vconsle相关的dom
  2. 像log、network等相关的渲染显示,都是通过重写window下对应的系统方法来加入一些自定义操作

完整源码可以先npm install vconsole后再在node_modules里面查看,或者在github上看 vconsole - github(opens new window)

#obs录制视频不是全屏的问题

解决方法:打开OBS => 点击顶部 "编辑" 按钮 => 选择 "变换" => 点击比例适配屏幕

#2020/06/21 周日

#视频拖动到imovie时间轴后,屏幕上方和下方被截断了

注意这种情况,需要在时间轴,选中视频,然后在预览器,点击 "全部还原",就可以了。

#2020/06/19 周五

#数组去重,去掉id重复的元素

有一个需求,客户信息列表,需要去除重复的客户。于是想着怎么写去重的逻辑,可以思考下

let customerList = [ 
  { id: '1', info: 'xxx' },
  { id: '3', info: 'xxx' },
  { id: '1', info: 'xxx' },
  { id: '2', info: 'xxx' },
  { id: '3', info: 'xxx' },
]

// 去重
let tempSet = new Set()
let newList = customerList.filter(item => {
  if (tempSet.has(item.id)) {
    return false
  }
  tempSet.add(item.id)
  return true
})
console.log(newList)
// [
//   { id: '1', info: 'xxx' },
//   { id: '3', info: 'xxx' },
//   { id: '2', info: 'xxx' },
// ]

#2020/06/17 周三

#axios取消请求,以及具体使用场景

以下是两个会用到取消当前请求的场景

  1. tab切换刷新某个列表数据数据导致数据错乱的问题
  2. 导出文件或下载文件时,中途取消
const CancelToken = axios.CancelToken;
const source = CancelToken.source();

// get 方法 假设这个接口需要50s才返回
axios.get('/user/12345', {
  cancelToken: source.token
}).catch(function (thrown) {
  if (axios.isCancel(thrown)) {
    console.log('Request canceled', thrown.message);
  } else {
    // handle error
  }
});

// post 方法时 假设这个接口需要50s才返回
// axios.post('/user/12345', {
//   name: 'new name'
// }, {
//   cancelToken: source.token
// })

// 3庙后取消请求
setTimeout(() => {
  // cancel the request (the message parameter is optional)
  source.cancel('Operation canceled by the user.');
}, 3000)

参考:axios cancellation | github(opens new window)

#2020/06/16 周二

#vue写一个js可以调用的toast组件

一般我们使用component属性来引入vue单文件组件,下面来介绍一种用js来挂载单文件组件的方法。这里以一个简单的toast组件来试试效果

// 将vue单文件组件挂载到dom的核心方法 create.js
import Vue from "vue";

export default function create(Component, props) {
  // 先创建实例
  const vm = new Vue({
    render(h) {
      // h就是createElement,它返回VNode
      return h(Component, { props });
    }
  }).$mount();

  // 手动挂载
  document.body.appendChild(vm.$el);

  // 销毁方法
  const comp = vm.$children[0];
  comp.remove = function() {
    document.body.removeChild(vm.$el);
    vm.$destroy();
  };
  return comp;
}

调用示例




简单的Toast.vue





#前端性能优化 - 缓存

在前端性能优化中,有一个方法是使用缓存。前端缓存可以减少网络请求次数,减少流量消耗,提升用户体验,降低服务器负载。

前端缓存分为两种:http缓存、浏览器缓存

#http缓存

相关文档可以搜索对应请求头的MDN文档、另外可以参考《http权威指南》第7章 缓存

http缓存策略的核心在于新鲜度检测,一般有两种策略来保持已缓存数据与服务器数据之间的充分一致

  1. 强缓存 - 设置文档过期时间(document expiration):给当前请求设置一个过期时间,这段时间内可以直接使用缓存数据,不必再去请求服务器拿数据
  2. 弱缓存/协商缓存 - 服务器再验证(server revalidation):服务器响应某个请求时,在响应头加一个版本信息或最后一次修改时间的标记,当下次该请求发生时,请求头会携带这个标记信息,服务器会通过标记值来判断是否使用缓存内容
#强缓存 document expiration

当第一次发送某个http请求后,会把内容缓存一段时间。在这段时间里,都认为内容是 "新鲜的",可以在不联系服务器的情况下,直接响应该文档。

Expires(相对时间)

Expires [ɪk'spaɪəz] 响应头包含日期/时间, 即在此时候之后,响应过期。无效的日期,比如 0, 代表着过去的日期,即该资源已经过期。如果在Cache-Control响应头设置了 "max-age" 或者 "s-max-age" 指令,那么 Expires 头会被忽略。

我们来写个demo试试,我们用koa来写个listData接口,将接口的Expires时间设置为当前时间+1小时,也就是一个小时后失效,koa代码如下

const Koa = require('koa')
const Router = require('koa-router')
const app = new Koa()
const router = new Router()

app.use(require('koa-static')(__dirname + '/public'))

router.get('/listData', ctx => {
  // 打印log,用来看服务端是否收到了请求
  console.log('recive req', ctx.url)
  // 实际效果:Expires: Sat Jul 04 2020 19:28:42 GMT+0800 (GMT+08:00)
  ctx.set('Expires', new Date(+new Date() + 3600 * 1000)) // UTC时间
  ctx.body = {
    info: 'hello world'
  }
})

app.use(router.routes()).use(router.allowedMethods())
app.listen(9000, () => {
  console.log('server start on 9000 port')
})

前端测试demo:public/index.html如下


  

当我们点击两次发送请求,测试结果如下:第一次请求时正常接收请求,由于我们设置了Expires响应头,前端再次请求时,服务端没有接收到请求,浏览器直接从硬盘读的缓存(from disk cache),响应头对比图如下

expires_header.png

参考:Expires - HTTP | MDN(opens new window)

Cache-Control

Expries是http1.0的标准,在HTTP/1.1版本里,Expire已经被Cache-Control替代,除了可以设置时间外,还加了更多控制,Cache-Control可以设置很多值

可缓存性相关值

  • public:所有内容都将被缓存(客户端和代理服务器都可缓存)
  • private:所有内容只有客户端可以缓存,(即代理服务器不能缓存它)Cache-Control的默认取值
  • no-cache:客户端缓存内容,但是是否使用缓存则需要经过协商缓存来验证决定(除非资源进行了再验证(协商缓存验证),否则客户端不会使用已换成的资源)
  • no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存(缓存应当尽快从服务器中删除文档的所有痕迹,因为其中可能包含敏感信息)

到期相关值

  • max-age=seconds 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与Expires相反,时间是相对于请求的时间。
  • s-maxage=seconds 覆盖max-age或者Expires头,但是仅适用于共享缓存(比如各个代理),私有缓存会忽略它。

我们将上个例子中设置Expires替换为Cache-Control的写法

ctx.set("Cache-control", "max-age=3600")  // 设置缓存有效时间为3600s,1个小时

执行结果和之前的基本一致,只是响应头有点变化,如下图

cachecontrol_max-age.png

这里要特别注意,Catch-Control不止响应头可以设置,前端在请求头也是可以设置的

Cache-Control: max-age= # 如果后端不设置改值,前端无效
Cache-Control: max-stale[=]
Cache-Control: min-fresh=
Cache-control: no-cache  # 可以使用,等价于 Pragma: no-cache
Cache-control: no-store
Cache-control: no-transform
Cache-control: only-if-cached

我们可以在axios请求时加请求头参数

let res = await  axios.get('/listData', {
  headers: {'Cache-Control': 'no-cache'}, // 如果前端使用这个请求头,那么后端设置的max-age或Expires就会被忽略
});

参考:Cache-Control - HTTP | MDN(opens new window)

#弱缓存/协商缓存 server revalidation

直接给某个请求设置缓存有效期是简单粗暴的方法,因为一般我们无法准确的知道需要缓存的资源具体可能会变动的时间。于是就有了服务器再验证的这种策略,注意下面讨论的是get请求,涉及到304状态码,对于412相关的post参见具体的mdn文档

week_cache.png

Last-Modified/If-Modified-Since 在响应某个请求时,响应头加一个Last-Modified,值设置为某个资源的最后一次修改时间。注意:这里会有一个问题,加了Last-Modified后,下次请求浏览器直接使用了强缓存,直接读disk cache,没有请求服务器,那我们服务器再验证就验证不了,所以需要设置一个no-cache的请求头,不使用强缓存,代码如下:

let fileInfo = fs.statSync('./public/index.html')
let mtime = fileInfo.mtime
ctx.set("Cache-control","no-cache");
ctx.set("Last-Modified", mtime)

这样设置后,下次请求时,请求头会自动携带If-Modified-Since,值为之前响应头设置的Last-Modified的值,然后我们根据If-Modified-Since请求头的时间,与某个文件修改的时间进行比对,如果相等,直接返回304状态码,浏览器会使用该接口上一次接口的缓存的数据

// 完整接口如下
router.get('/listData', ctx => {
  console.log('recive req', ctx.url)
  let fileInfo = fs.statSync('./public/index.html')
  let mtime = fileInfo.mtime
  // console.log(fileInfo, fileInfo.mtime)
  // 响应头设置后,下次这个请求会在请求头自动加上 If-Modified-Since: Sat Jul 04 2020 21:19:30 GMT+0800 (GMT+08:00) 字段
  console.log(ctx.headers)
  ctx.set("Cache-control","no-cache");
  ctx.set("Last-Modified", mtime)
  if (ctx.headers['if-modified-since'] && ctx.headers['if-modified-since'] == mtime) {
    console.log("304");
    ctx.status = 304
  } else {
    ctx.body = {
      info: 'hello world'
    }
  }
})

注意:当与 If-None-Match 一同出现时,它(If-Modified-Since)会被忽略掉,除非服务器不支持 If-None-Match。

参考:

  • Last-Modified - HTTP | MDN(opens new window)
  • If-Modified-Since - HTTP | MDN(opens new window)

ETag/if-None-Macth

有些请求验证最后修改时间是不够的,比如:

  • 某些文件会被周期性的重写,但文件内容是一样的,可最后一次修改时间变了
  • 有些文档被改了,但改动并不重要,比如拼写或注释的修改,不需要让所有缓存都更新
  • 有些文档会在亚秒间发生变化,对设置服务器来说,1s的粒度会不够用

因此,Last-Modified由于精确度比ETag(实体标签)要低,所以Last-Modified只是一个备用机制。一般Last-Modified设置的是修改时间,而ETag设置的一般是某个资源的标识符(版本号或者md5,表示一个文件的指纹fingerprints),来看代码

const calcMd5 = require('./md5')
router.get('/listData', (ctx, next) => {
  console.log('recive req', ctx.url)
  let md5 =  calcMd5('./public/index.html')
  ctx.set("Cache-control","no-cache");
  ctx.set('ETag', md5)
  if (ctx.headers['if-none-match'] && ctx.headers['if-none-match'] === md5) {
    console.log("304");
    ctx.status = 304
  } else {
    ctx.body = {
      info: 'hello world'
    }
  }
})

md5.js 计算文件md5

const crypto = require('crypto');
const fs = require('fs');

module.exports = (filePath) => {
    //读取一个Buffer
    let buffer = fs.readFileSync(filePath);
    let fsHash = crypto.createHash('md5');

    fsHash.update(buffer);
    return fsHash.digest('hex');
}

参考:

  • ETag - HTTP | MDN(opens new window)
  • If-None-Match - HTTP| MDN(opens new window)
#Chrome下注意事项

在进行缓存测试时,你会发现浏览器的真实情况和书上或文档里的会有一点不一样的地方,我们需要注意:

  1. 如果ETag和If-None-Match的值一致,且koa返回了304,但前端一直是200时,可能是浏览器的问题,关掉浏览器当前页面,再打开可能就好了。这一块调的我差点怀疑人生
  2. 书上或文档里说,no-cache只是不使用强缓存,需要服务器二次验证、而no-store是既不使用强缓存也不使用协商缓存,但是在chrome浏览器里,如果你的请求头里有Cache-Control': 'no-cache',不管是强缓存还是协商缓存都不会生效;
  3. 在F12 chrome调试面板,勾选disable cache,请求头会自动携带Pragma: no-cache,等价于请求头里设置cache-control: no-cache
  4. 在chrome浏览器下,请求头里使用Cache-Control': 'no-store',没有任何用处

关于以上完整的HTTP缓存测试demo,参见 http缓存测试 | github(opens new window)

参考:

  • Pragma - HTTP | MDN(opens new window)

#浏览器缓存

cookie或storage

#2020/06/15 周一

#macos大小写不敏感,修改文件名大小写后git会异常

macos是文件大小写不敏感的系统,而widonws/linux是大小写敏感的,假设把git上之前提交过的文件,修改大小写,再次提交后,git上会出现两个大小写不一样的文件?为什么呢

macos是大小写不敏感的文件系统,也就是在macos上你无法在同一个目录下同时创建test.vue和Test.vue,如果出现这种情况,本地感知不到文件的改动,但git服务器却有记录这两个文件。

最好的解决方法是将整个文件名都变更。而不是仅改变大小写

#echarts画高自定义仪表盘,echart本质是图的堆叠

最近有个UI需求,画一个自定义的仪表盘,看起来很不好实现,后面在看了echarts社区的一些例子,经过写demo测试后,找到了规律,核心是化繁为简,对于复杂的UI,可以拆分为多个小的模块,一个个实现

echarts_round.png

下面是一个很粗糙的测试demo,option如下,完整demo参见: 自定义仪表盘 | github(opens new window)

var placeHolderStyle = {
  normal: {
    label: {
      show: false
    },
    labelLine: {
      show: false
    },
    color: "rgba(0,0,0,0)",
    borderWidth: 0
  },
  emphasis: {
    color: "rgba(0,0,0,0)",
    borderWidth: 0
  }
};

var bg = [
  {
    type: "pie",
    radius: "53%", // 半径
    center: [
      // 圆心
      "40%",
      "50%"
    ],
    z: 1,
    itemStyle: {
      normal: {
        color: {
          type: "linear",
          x: 0,
          y: 0,
          x2: 0,
          y2: 1,
          colorStops: [
            {
              offset: 0,
              color: "#ffffff" // 0% 处的颜色
            },
            {
              offset: 1,
              color: "#f3f4f9" // 100% 处的颜色
            }
          ],
          global: false // 缺省为 false
        },
        label: {
          show: false
        },
        labelLine: {
          show: false
        }
      }
    },
    hoverAnimation: false,
    label: {
      show: false
    },
    tooltip: {
      show: false
    },
    data: [
      {
        value: 70 // 背景部分
      },
      {
        value: 30, // 空缺部分
        itemStyle: {
          color: "transparent"
        }
      }
    ],
    startAngle: 216
  }
];

// 仪表盘外层
var roundList = [
  {
    type: "pie",
    hoverAnimation: true, //鼠标经过的特效
    radius: ["50%", "55%"],
    center: ["40%", "50%"],
    startAngle: 180,
    labelLine: {
      normal: {
        show: false
      }
    },
    label: {
      normal: {
        position: "center"
      }
    },
    data: [
      {
        value: 100, // 显示长度
        itemStyle: {
          normal: {
            color: "rgba(236,172,112, 1.0)" // 橙色 1
          }
        }
      },
      {
        value: 100, // 总长度
        itemStyle: placeHolderStyle
      }
    ]
  },

  {
    type: "pie",
    hoverAnimation: false, //鼠标经过的特效
    radius: ["50%", "55%"], // 内半径、外半径
    center: ["40%", "50%"], // 圆心坐标, 距离左侧、顶部
    startAngle: 180, // 0 为圆心左侧开始,起始角度,支持范围[0, 360]。
    labelLine: {
      normal: {
        show: false
      }
    },
    label: {
      normal: {
        position: "center"
      }
    },
    data: [
      {
        value: 11,
        itemStyle: {
          normal: {
            color: "white" // 3 紫色
          }
        }
        // label: dataStyle, 显示标签
      },
      {
        value: 100,
        itemStyle: placeHolderStyle
      }
    ]
  },

  //上层环形配置
  {
    type: "pie",
    hoverAnimation: false, //鼠标经过的特效
    radius: ["50%", "55%"], // 内半径、外半径
    center: ["40%", "50%"], // 圆心坐标, 距离左侧、顶部
    startAngle: 180, // 0 为圆心左侧开始,起始角度,支持范围[0, 360]。
    labelLine: {
      normal: {
        show: false
      }
    },
    label: {
      normal: {
        position: "center"
      }
    },
    data: [
      {
        value: 10,
        itemStyle: {
          normal: {
            color: "rgba(170,185,227,1.0)" // 3 紫色
          }
        }
        // label: dataStyle,  // label: dataStyle, 显示标签
      },
      {
        value: 100,
        itemStyle: placeHolderStyle
      }
    ]
  }
];

var pointer = [
  {
    name: "指针",
    type: "gauge",
    title: {
      show: false
    },
    detail: {
      show: false
    },
    data: [
      {
        value: 29
      }
    ],
    radius: "55%", // 内半径、外半径
    center: ["40%", "50%"], // 圆心坐标, 距离左侧、顶部
    itemStyle: {
      color: "#000"
    },
    axisLine: {
      lineStyle: {
        show: false,
        width: 0
      }
    },
    axisLabel: {
      show: false
    },
    axisTick: {
      show: false
    },
    splitLine: {
      show: false
    },
    pointer: {
      show: true,
      length: "80%",
      width: 40
    }
  }
];

var circles = [
  {
    type: "pie",
    radius: "80", // 半径
    center: [
      // 圆心
      "40%",
      "50%"
    ],
    z: 10,
    itemStyle: {
      normal: {
        color: "#fff",
        label: {
          show: true
        },
        labelLine: {
          show: false
        },
        shadowColor: "rgba(122, 122, 122, 0.21)",
        shadowBlur: 30
      }
    },
    animation: false,
    tooltip: {
      show: false
    },
    data: [
      {
        value: 36, // 背景部分
        label: {
          normal: {
            formatter: "{c}%",
            position: "center",
            show: true,
            textStyle: {
              fontSize: "40",
              fontFamily: "Impact",
              fontWeight: "normal",
              color: "#4a4a4a"
            }
          }
        }
      }
    ],
    startAngle: 0,
    text: "36%",
    textStyle: {
      color: "red"
    }
  },
  {
    name: "Line",
    type: "pie", // 圆圈
    clockWise: false,
    radius: ["28%", "28.5%"],
    center: [
      // 圆心
      "40%",
      "50%"
    ],
    z: 11,
    tooltip: {
      show: false
    },
    label: {
      show: false
    },
    animation: false,
    data: [
      {
        value: 100,
        itemStyle: {
          color: "#ececec"
        }
      }
    ]
  }
];

var texts = [];

var option = {
  backgroundColor: "#fff",
  title: [
    {
      text: "0",
      left: "13%",
      top: "51%",
      textAlign: "center",
      textStyle: {
        fontWeight: "normal",
        fontFamily: "PingFangSC-Semibold",
        fontSize: "16",
        color: "#4a4a4a",
        textAlign: "center"
      }
    },
    {
      text: "48万",
      left: "65%",
      top: "51%",
      textAlign: "center",
      textStyle: {
        color: "#4a4a4a",
        fontFamily: "PingFangSC-Semibold",
        fontWeight: "normal",
        fontSize: "16",
        textAlign: "center"
      }
    }
  ],
  series: [
    ...bg, // 背景
    ...roundList, // 最外面顶部样式
    ...pointer, // 指针
    ...circles, // 中间圆圈部分
    ...texts // 左右侧文字
  ]
};

console.log(option);

#输入过程中,怎么实时高亮部分文字(@xxx高亮实现)

input_highlight_key.gif

实现思路是,使用富文本编辑器,当监听到输入的文件包含关键字时,使用replace对当前的输入内容进行替换,给关键字加上span给个highlight的class,代码如下

  1. replace后,光标移动到了输入框最前面
// 解决方法:replace后,把输入光标移动到最后面
// set 光标到末尾
// https://www.cnblogs.com/jonie-wong/p/5519822.html
// div输入@光标定位
// 知乎div编辑器,@功能的光标定位问题
// https://segmentfault.com/q/1010000005617160
function focusToElementEnd(el) {
  el.focus();
  if (!window.getSelection) {
    var range = document.selection.createRange();
    this.last = range;
    range.moveToElementText(el);
    range.select();
    document.selection.empty(); //取消选中
  }
  else {
    var range = document.createRange();
    range.selectNodeContents(el);
    range.collapse();
    var sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  }
}

  1. 替换后虽然高亮了,但再次输入的文字,也会一直是高亮的,注意,后面输入的字母没有放到replace span后面
// [Log] 当前富文本输入内容  "123zuo" 
// [Log] zuo "123zuo"
// [Log] 替换后,再次获取富文本输入内容 "123zuo"

// [Log] 当前富文本输入内容 – "123zuok"
// [Log] zuo – "123zuok"

网上找了下,最相似的答案是 实现动态输入关键字时关键字高亮 (opens new window),从这里捕捉到一个很重要的信息,就是里面提到的是替换text而不是html,而我替换的是innerHTML,所以我换了下思路

在onkeyup键盘抬起时对textContent进行replace,解决了这个问题

const keyArr = ['今天', '明天', '上午', '下午', '11点', '12点', 'zuo']
let editdiv = document.querySelector('#editdiv')
editdiv.onkeyup = (e) => {
  console.log('onkeyup', e, editdiv.textContent)
  // document.execCommand('forecolor', false, 'black')
  let curValue = editdiv.textContent
  let needHighlightArr = keyArr.filter(item => curValue.includes(item))
  // 开始高亮
  needHighlightArr.forEach(item => {
    console.log(item, curValue)
    // 如果之前已经设置了highlight,就不设置了 class="highlight"
    let nextText = `${item}`
    if (!curValue.includes(nextText)) {
      curValue = curValue.replace(item, nextText)
    }
  })
  editdiv.innerHTML = curValue
  focusToElementEnd(editdiv) // 将光标移动到末尾
}
  1. 上面的demo还存在一个问题,就是在输入法组合文件的过程中也会进行replace,导致中文无法输入,这里我们结合之前研究v-model无法实时监听输入法组合文字过程 (opens new window)的经验。当组合文字时,不进行替换

以上,我们基本实现了输入过程中实时高亮关键字的功能,替换的细节还需要优化,这里只是一个实现思路。完整demo参见: 输入内容过程中高亮关键字 | github(opens new window)

#v-loading指令的实现,怎么通过一个指令自动加骨架屏

这里需要用到vue自定义指令,我们先写个v-zloading来实现loading,在loading过程中加骨架屏

测试
v_loading.gif

由于是全局自定义指令,所以在main.js里写

// 挂载dom
function mountDom(el) {
  el.style.position = "relative";

  let div = document.createElement("div");
  div.style.position = "absolute";
  div.style.top = "0";
  div.style.left = "0";
  div.style.right = "0";
  div.style.bottom = "0";
  div.style.backgroundColor = "white";
  div.classList.add("zloading");

  let htmlStr = `
    
`; div.innerHTML = htmlStr; el.appendChild(div); } Vue.directive("zloading", { bind: (el, binding) => { // console.log("v-zloading bind"); // console.log(binding, vnode); if (binding.value) { mountDom(el); } }, update: (el, binding) => { // console.log("v-zloading update"); // console.log(el, binding, vnode); let zloadingDom = el.querySelector(".zloading"); // console.log("zloading dom", zloadingDom); if (zloadingDom) { zloadingDom.style.display = binding.value ? "block" : "none"; } else { binding.value && mountDom(el); } } });

骨架屏的样式common.css,然后在App.vue里引入

.fast-loading {
    height: 20px;
    margin: 10px 0;
    width: 200px;
    background-color: rgb(245, 245, 245);
    background-image: repeating-linear-gradient(90deg, #eee, #f5f5f5 100%);

    animation-name: fastLoading;
    animation-timing-function: linear;
    animation-duration: 1s;
    animation-iteration-count: infinite;
}
@keyframes fastLoading {
    from {
    background-position: 0 0;
    }
    to {
    background-position: 100px 0;
    }
}

.w100 { width: 100% }
.w80 { width: 80% }
.w60 { width: 60% }
.w40 { width: 50% }
.w30 { width: 30% }

这样就模拟实现了一个v-loading, 完整demo参见v-zloading 实现 | github (opens new window)注意,指令的实现是放在main.js里面的

我们自己简单实现v-loaidng的功能后,再来看看v-loading的源码

// 截取至element v-loading部分源码 
// https://github.com/ElemeFE/element/blob/dev/packages/loading/src/directive.js
Vue.directive('loading', {
    bind: function(el, binding, vnode) {
      //....
      const mask = new Mask({
        el: document.createElement('div'),
        data: {
          text: vm && vm[textExr] || textExr,
          spinner: vm && vm[spinnerExr] || spinnerExr,
          background: vm && vm[backgroundExr] || backgroundExr,
          customClass: vm && vm[customClassExr] || customClassExr,
          fullscreen: !!binding.modifiers.fullscreen
        }
      });
      el.instance = mask;
      el.mask = mask.$el;
      el.maskStyle = {};

      // 如果v-loading设置的值为true,挂载maskdom(toggleLoading方法)
      binding.value && toggleLoading(el, binding);
    },

    update: function(el, binding) {
      el.instance.setText(el.getAttribute('element-loading-text'));
      if (binding.oldValue !== binding.value) {
        toggleLoading(el, binding);
      }
    },

    unbind: function(el, binding) {
      if (el.domInserted) {
        el.mask &&
        el.mask.parentNode &&
        el.mask.parentNode.removeChild(el.mask);
        toggleLoading(el, { value: false, modifiers: binding.modifiers });
      }
      el.instance && el.instance.$destroy();
    }
  });

回过头来看看之前element UI在IE下可能会出现的两个bug

  1. element v-loading在IE下可能会溢出到全屏的问题,我的理解是,我觉得主要的核心在于在v-loading作用的元素上添加position:relative没有成功
// ...
if (el.originalPosition !== 'absolute' && el.originalPosition !== 'fixed') {
  addClass(parent, 'el-loading-parent--relative');
}
// ...

  1. element v-loading在IE下可能会关不掉的问题,我的理解是,当binding.value有值时,它是在Vue.nextTick下次渲染周期才挂载loading的dom,如果设置true,false间隔非常快,还没在下个渲染周期,那么其实loading已经是false了,但它才开始挂载,导致关闭不了
// ...
if (binding.value) {
    Vue.nextTick(() => {
      if (binding.modifiers.fullscreen) {
        el.originalPosition = getStyle(document.body, 'position');
        el.originalOverflow = getStyle(document.body, 'overflow');
        el.maskStyle.zIndex = PopupManager.nextZIndex();

        addClass(el.mask, 'is-fullscreen');
        insertDom(document.body, el, binding);
      } else {
        removeClass(el.mask, 'is-fullscreen');
// ...

后面有机会在 node_modules 下修改源码加上console,然后在IE下调试看看,这个是目前来说最好的方法。

参考:

  • vue自定义指令 | Vue.js(opens new window)
  • vue自定义指令笔记 | 语雀(opens new window)

#element v-loading在IE下可能会溢出到全屏的问题

在IE下,有可能出现v-loading指令在loading时不是作用在添加 v-loading 指令的元素区域里,而是溢出到全屏了。

这种情况我查了下dom,了解到v-loading是position: absolute布局,而v-loading position不是relatvie 导致溢出到全屏了。这种情况给使用v-loading指令的元素手动加一个 position: relative就可以了。

#element v-loading在IE下可能会关不掉的问题

对于请求非常快的情况,loading一加载很快就关闭。在IE下,可能出现 v-loading设置的值已经是 false,但loading还是一直显示,关闭不了的情况,解决方法是:在关闭loading前,加一个500ms的延时,就没问题了。

setTimeout(() => {
  this.loading = false
}, 500)

#macOS下怎么将mp4和m4a文件合并

可以使用ffmpeg命令行工具,输入如下命令进行合并,注意这个合并时间非常长,要等很久

# 下载ffmpeg解压后会有一个ffmpeg-4.2.3-macos64-static文件夹,然后进入这个目录的bin目录下,执行
./ffmpeg -i /Users/kevin/Desktop/17下.mp4 -i /Users/kevin/Desktop/17下.m4a out2.mp4

其实合并视频和音频可以使用mac下的imovie或者一些其他的视频剪辑软件,比这个要快很多

#判断一个字符串出现次数最多的字符,并输出其次数

let str = "ababadectwestsfdadsfb"
console.log(getMostChar(str))

function getMostChar(str) {

}

思路:我们先实现获取字符串中每个字符出现的次数

function getCharCount(str) {
  // 先把字符串切分为数组
  return str.split('').reduce((result, item) => {
    if (result[item] === undefined) {
      result[item] = 1
    } else {
      result[item]++
    }
    return result
  }, {})
}

上面的例子中,使用了Array.prototype.reduce,如果不知道reduce执行情况,就需要好好看下mdn (opens new window)的文档了,上面的代码其实等价于下面的代码,可以看到reduce使用第二参数时,

function getCharCount(str) {
  // 先把字符串切分为数组
  let result = {}
  str.split('').forEach(item => {
    if (result[item] === undefined) {
      result[item] = 1
    } else {
      result[item]++
    }
  })
  return result
}

执行下这个函数

console.log('每个字符串出现的次数', JSON.stringify(getCharCount(str)))
// {"a":4,"b":3,"d":3,"e":2,"c":1,"t":2,"w":1,"s":3,"f":2}

根据上面的例子,我们再来思考怎么获取出现次数最多的字符,其实在reduce里面每次的result我们可以获取到当前字符、以及其出现的次数。我们可以用一个变量来存储出现次数最多的字符,每次遍历是都与这个变量进行比较就可以了

let str = "ababadectwestsfdadsfb"
function getMostChar(str) {
  // 先把字符串切分为数组
  let mostChar = {
    count: 0,
    char: ''
  }

  let charCountObj = str.split('').reduce((result, item) => {
    if (result[item] === undefined) {
      result[item] = 1
    } else {
      result[item]++
    }

    if (result[item] > mostChar.count) {
      mostChar.count = result[item]
      mostChar.char = item
    }

    return result
  }, {})

  return {
    mostChar, // 出现次数最多的字符
    charCountObj, // 字符串中所有字符出现的次数
  }
}

上面的例子中mostChar输出了出现次数最多的字符以及其次数,那问题来了,如果字符串是 "aabbcc",那出现最多次数的字符就不只一个了,可能会是数组。我们需要对上面的例子再进行一些增强,如下

// 主要是修改当前字符和出现次数最多的字符相等的情况
if (result[item] > mostChar.count) {
  mostChar.count = result[item]
  mostChar.char = item
} else if (result[item] === mostChar.count) {
  if (typeof mostChar.char === 'string') {
    mostChar.char = [mostChar.char, item]
  } else {
    mostChar.char.push(item)
  }
}

以上就是完整实现了,完整demo参见:判断一个字符串出现最多的字符 | github(opens new window)

#2020/06/13 周六

#Symbol和Array.prototype.includes不兼容IE,有babel就可以,他的原理是什么

在mdn上可以查相关API的兼容性,可以看到Symbol和Array.prototype.includes是不支持IE的,但我们在vue-cli的项目中,发现使用了这些api,在IE下也可以运行,这是为什么呢?

主要是安装了babel,babel有进行转换,当IE不支持某个方法时,会使用替代的pollyfill,那它是怎么打包进项目的?

#什么是babel?

为什么会有babel,babel出现的原因是JS的一些很好的新特性在一些低浏览器版本或者IE下无法使用。为了用最新的JS语法特性写的代码在某些不兼容的环境下也可以正常运行,babel应运而生。

Babel is a JavaScript compiler,Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments. Here are the main things Babel can do for you:

babel是一个js编译器,他是一个工具链(toolchain),它主要用于将 ES6(ES2015)+ 的代码转换为在低版本浏览器或执行环境可以跑起来的代码。他主要做了以下事情:

  1. Transform syntax(语法转换)
  2. Polyfill features that are missing in your target environment (through @babel/polyfill) (当浏览器会运行环境不支持某些API时,通过使用@babel/polyfill补充上对应的API实现内容(polyfill features))
  3. Source coude transformations(codemods) 源码转换
  4. And more!(check out these videos for inspiration)(更多,查看这些视频 (opens new window)以获取灵感)
// Babel Input: ES2015 arrow function
[1, 2, 3].map((n) => n + 1);

// Babel Output: ES5 equivalent
[1, 2, 3].map(function(n) {
  return n + 1;
});

#关于@babel/polyfill与core-js

一般polyfill只会提供es2015+相关的内容,且当某些新特性还没到 Stage 4 proposals(第4阶段的提议)之前,是不支持的

As of Babel 7.4.0, this package has been deprecated in favor of directly including core-js/stable (to polyfill ECMAScript features) and regenerator-runtime/runtime (needed to use transpiled generator functions):

在 Babel 7.4.0 中,@babel/polyfill这个包被废弃了,现在由 core-js/stable (opens new window)来polyfill features

import "core-js/stable";
import "regenerator-runtime/runtime";

参考 @babel/polyfill(opens new window)

我们在core-js的源码中找找 symbol 和includes的实现,一般源码目录在 core-js/internals 目录下,下面是 include polyfill的实现

// /core-js/internals/array-includes.js
var toIndexedObject = require('../internals/to-indexed-object');
var toLength = require('../internals/to-length');
var toAbsoluteIndex = require('../internals/to-absolute-index');

// `Array.prototype.{ indexOf, includes }` methods implementation
var createMethod = function (IS_INCLUDES) {
  return function ($this, el, fromIndex) {
    var O = toIndexedObject($this);
    var length = toLength(O.length);
    var index = toAbsoluteIndex(fromIndex, length);
    var value;
    // Array#includes uses SameValueZero equality algorithm
    // eslint-disable-next-line no-self-compare
    if (IS_INCLUDES && el != el) while (length > index) {
      value = O[index++];
      // eslint-disable-next-line no-self-compare
      if (value != value) return true;  // ??????
    // Array#indexOf ignores holes, Array#includes - not
    } else for (;length > index; index++) {
      if ((IS_INCLUDES || index in O) && O[index] === el) return IS_INCLUDES || index || 0;
    } return !IS_INCLUDES && -1;
  };
};

module.exports = {
  // `Array.prototype.includes` method
  // https://tc39.github.io/ecma262/#sec-array.prototype.includes
  includes: createMethod(true),
  // `Array.prototype.indexOf` method
  // https://tc39.github.io/ecma262/#sec-array.prototype.indexof
  indexOf: createMethod(false)
};

tc39官网介绍:When the includes method is called, the following steps are taken:

// https://tc39.github.io/ecma262/#sec-array.prototype.includes
1. Let O be ? ToObject(this value).
2. Let len be ? LengthOfArrayLike(O).
3. If len is 0, return false.
4. Let n be ? ToInteger(fromIndex).
5. Assert: If fromIndex is undefined, then n is 0.
6. If n ≥ 0, then
     Let k be n.
   Else,
     Let k be len + n.
     If k < 0, set k to 0.
8. Repeat, while k < len,
     Let elementK be the result of ? Get(O, ! ToString(k)).
     If SameValueZero(searchElement, elementK) is true, return true.
     Set k to k + 1.
9. Return false.

关于 sameValueZero算法,参见 samevaluezero tc39(opens new window)

#symbol polyfill实现

// `Symbol.prototype.description` getter
// https://tc39.github.io/ecma262/#sec-symbol.prototype.description
// /core-js/modules/es.symbol.description.js
var NativeSymbol = global.Symbol;

if (DESCRIPTORS && typeof NativeSymbol == 'function' && (!('description' in NativeSymbol.prototype) ||
  // Safari 12 bug
  NativeSymbol().description !== undefined
)) {
  var EmptyStringDescriptionStore = {};
  // wrap Symbol constructor for correct work with undefined description
  var SymbolWrapper = function Symbol() {
    var description = arguments.length < 1 || arguments[0] === undefined ? undefined : String(arguments[0]);
    var result = this instanceof SymbolWrapper
      ? new NativeSymbol(description)
      // in Edge 13, String(Symbol(undefined)) === 'Symbol(undefined)'
      : description === undefined ? NativeSymbol() : NativeSymbol(description);
    if (description === '') EmptyStringDescriptionStore[result] = true;
    return result;
  };
  copyConstructorProperties(SymbolWrapper, NativeSymbol);
  var symbolPrototype = SymbolWrapper.prototype = NativeSymbol.prototype;
  symbolPrototype.constructor = SymbolWrapper;

  var symbolToString = symbolPrototype.toString;
  var native = String(NativeSymbol('test')) == 'Symbol(test)';
  var regexp = /^Symbol\((.*)\)[^)]+$/;
  defineProperty(symbolPrototype, 'description', {
    configurable: true,
    get: function description() {
      var symbol = isObject(this) ? this.valueOf() : this;
      var string = symbolToString.call(symbol);
      if (has(EmptyStringDescriptionStore, symbol)) return '';
      var desc = native ? string.slice(7, -1) : string.replace(regexp, '$1');
      return desc === '' ? undefined : desc;
    }
  });

#babel是怎么自动打包进vue-cli项目里的

在babel github官方仓库里面有一个 babel-loader 项目,主要是Babel loader for webpack,另外vue-cli的package.json里也引入了 @vue/cli-plugin-babel,后面有时间再深入研究

#用koa mock接口时router.allowedMethods()这个中间件是用来做什么的?

app.use(router.routes()).use(router.allowedMethods())

为什么要加router.allowedMethods()中间件呢?我们写个demo来测试下

let koa = require('koa');
let Router = require('koa-router')

let app = new koa()
let router = new Router()

router.post('/user', ctx => {
  ctx.body = {
    a: 1
  }
})

app.use(router.routes())
// app.use(router.routes()).use(router.allowedMethods())

app.listen('9000', () => {
  console.log('server listen on 9000 port')
})

上面的例子中,我们定义一个 /user 接口,他需要使用post请求方法。这里我们先通过get方法来请求这个接口试试

curl -v http://127.0.0.1:9000/user
# 以下是请求响应的内容,可以看到接口会返回404,因为我们接口现在只能是post请求的
* Connected to 127.0.0.1 (127.0.0.1) port 9000 (#0)
> GET /user HTTP/1.1
> Host: 127.0.0.1:9000
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 404 Not Found
< Content-Type: text/plain; charset=utf-8
< Content-Length: 9
< Date: Thu, 25 Jun 2020 09:13:14 GMT
< Connection: keep-alive
< 

我们再调整下上面的demo,使用 router.allowedMethods() 中间件

// app.use(router.routes())
app.use(router.routes()).use(router.allowedMethods())

再发一遍get请求

curl -v http://127.0.0.1:9000/user
# 以下是返回结果
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 9000 (#0)
> GET /user HTTP/1.1
> Host: 127.0.0.1:9000
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 405 Method Not Allowed
< Allow: POST
< Content-Type: text/plain; charset=utf-8
< Content-Length: 18
< Date: Thu, 25 Jun 2020 09:13:55 GMT
< Connection: keep-alive
< 

我们可以看到加router.allowedMethods()中间件和不加这个中间件的区别,当我们定义了post方法接口却发送对应的get请求时,执行结果对比

类型 响应状态码 响应头变化
默认情况 404 Not Found
router.allowedMethods() 405 Method Not Allowed 新增响应头 Allow: POST

下面我们来看下对应的源码:koa-router源码 | github(opens new window)

由于它是一个npm包,我们先在package.json里面看看他的入口

# package.json里面的main就是我们 require对应npm包后,引入的实际文件地址
"main": "lib/router.js",

也就是源码入口在lib/router.js

// 源码截取至https://github.com/koajs/router/blob/master/lib/router.js
/**
 * Returns separate middleware for responding to `OPTIONS` requests with
 * an `Allow` header containing the allowed methods, as well as responding
 * with `405 Method Not Allowed` and `501 Not Implemented` as appropriate.
 * @param {Object=} options
 * @param {Boolean=} options.throw throw error instead of setting status and header
 * @param {Function=} options.notImplemented throw the returned value in place of the default NotImplemented error
 * @param {Function=} options.methodNotAllowed throw the returned value in place of the default MethodNotAllowed error
 * @returns {Function}
 */
Router.prototype.allowedMethods = function (options) {
  options = options || {};
  const implemented = this.methods;

  return function allowedMethods(ctx, next) {
    return next().then(function() {
      const allowed = {};

      if (!ctx.status || ctx.status === 404) {
        for (let i = 0; i < ctx.matched.length; i++) {
          const route = ctx.matched[i];
          for (let j = 0; j < route.methods.length; j++) {
            const method = route.methods[j];
            allowed[method] = method;
          }
        }

        const allowedArr = Object.keys(allowed);

        if (!~implemented.indexOf(ctx.method)) {
          if (options.throw) {
            let notImplementedThrowable = (typeof options.notImplemented === 'function')
            ? options.notImplemented()  // set whatever the user returns from their function
            : new HttpError.NotImplemented();

            throw notImplementedThrowable;
          } else {
            ctx.status = 501;
            ctx.set('Allow', allowedArr.join(', '));
          }
        } else if (allowedArr.length) {
          if (ctx.method === 'OPTIONS') {
            ctx.status = 200;
            ctx.body = '';
            ctx.set('Allow', allowedArr.join(', '));
          } else if (!allowed[ctx.method]) {
            if (options.throw) {
              let notAllowedThrowable = (typeof options.methodNotAllowed === 'function') 
              ? options.methodNotAllowed() // set whatever the user returns from their function
              : new HttpError.MethodNotAllowed();

              throw notAllowedThrowable;
            } else {
              ctx.status = 405;
              ctx.set('Allow', allowedArr.join(', '));
            }
          }
        }
      }
    });
  };
};

怎么弄清楚源码的执行呢?一般我们可以在源码里加入一些console.log,来打印一些关键的信息,注意:

  1. 一般npm install koa-router --save后,当前目录下的node_modules里面会有对应的源码,可以在里面修改源码
  2. 修改源码后,需要ctrl+s一下index.js触发nodemon重启服务,这样执行的才是修改过源码后的代码

以下是我在调试这个demo时,在源码中加的console信息,如下

Router.prototype.allowedMethods = function (options) {
  options = options || {};
  const implemented = this.methods;

  console.log('koa router log, implemented', implemented)
  // [ 'HEAD', 'OPTIONS', 'GET', 'PUT', 'PATCH', 'POST', 'DELETE' ]

  return function allowedMethods(ctx, next) {
    return next().then(function() {
      const allowed = {};

      if (!ctx.status || ctx.status === 404) {
        console.log('ctx.matched', ctx.matched)
        //  ctx.matched 当前请求匹配到的路由,当发送 /user 请求时,这个数组只有一个元素
        // [ Layer {
        //   opts:
        //    { end: true,
        //      name: null,
        //      sensitive: false,
        //      strict: false,
        //      prefix: '',
        //      ignoreCaptures: undefined },
        //   name: null,
        //   methods: [ 'POST' ],
        //   paramNames: [],
        //   stack: [ [Function] ],
        //   path: '/user',
        //   regexp: /^\/user[\/#\?]?$/i } ]
        for (let i = 0; i < ctx.matched.length; i++) {
          const route = ctx.matched[i];
          // 有些接口可能支持多种methods请求,这里遍历当前接口支持的所有方法数组,/user 只支持一个post方法
          for (let j = 0; j < route.methods.length; j++) {
            const method = route.methods[j];
            allowed[method] = method;
          }
        }
        console.log('allowed', allowed) // { POST: 'POST' }

        const allowedArr = Object.keys(allowed); // ['POST']

        console.log('implemented.indexOf(ctx.method)', implemented.indexOf(ctx.method)) // 2

        // if (!~value) 等价于 if (value === -1)
        if (!~implemented.indexOf(ctx.method)) { // ctx.method  GET
          // 如果当前请求方法不是下面数组中的某一种
          // [ 'HEAD', 'OPTIONS', 'GET', 'PUT', 'PATCH', 'POST', 'DELETE' ]
          if (options.throw) {
            let notImplementedThrowable = (typeof options.notImplemented === 'function')
            ? options.notImplemented()  // set whatever the user returns from their function
            : new HttpError.NotImplemented();

            throw notImplementedThrowable;
          } else {
            ctx.status = 501;
            ctx.set('Allow', allowedArr.join(', '));
          }
        } else if (allowedArr.length) {
          // 当前路由404,但对应的接口可以使用其他的method进行请求
          if (ctx.method === 'OPTIONS') {
            ctx.status = 200;
            ctx.body = '';
            ctx.set('Allow', allowedArr.join(', '));
          } else if (!allowed[ctx.method]) { // allowed: { POST: 'POST' } ctx.method: GET
            // 当前请求方法,并不在接口允许的方法(allowed)里面
            if (options.throw) {
              // throw error instead of setting status and header
              let notAllowedThrowable = (typeof options.methodNotAllowed === 'function') 
              ? options.methodNotAllowed() // set whatever the user returns from their function
              : new HttpError.MethodNotAllowed();

              throw notAllowedThrowable;
            } else {
              // 默认请求,如果不传 throw方法的情况
              ctx.status = 405;
              ctx.set('Allow', allowedArr.join(', '));
            }
          }
        }
      }
    });
  };
};

总结,router.allowedMethods() 的执行逻辑大致如下

  1. 如果当前接口为404时(!ctx.status的情况貌似没遇到过),才执行该中间件的逻辑
  2. 遍历当前请求匹配到的路由信息数组 ctx.matched,将匹配到的路由允许的methods存入 allowed 对象
  3. 判断当前请求方法ctx.method是否是正常的请求方法,如果不是,抛异常,注意抛异常时,如果调用该中间件时有传入throw参数,则表示自己处理异常,这种情况默认返回501,提示服务异常
// 注意这里用到了 !~ 来判断是否 === -1,这里可以使用ES2016新出的Array.prototype.includes来判断
// if (!~value) 等价于 if (value === -1)
if (!~implemented.indexOf(ctx.method)) { //

  1. 如果当前路由404,但对应的接口可以使用其他的method进行请求,如果是 options(或预检请求),不返回404,返回200,并设置allow响应头
  2. 如果当前请求方法不在允许的方法里面,如果传入了throw自己处理异常,否则返回 405 method not allowed,且设置allow响应头

#2020/06/12 周五

#component用is进行组件切换时会触发哪些钩子函数,加了keep-alive后呢?

假设动态组件component使用is控制组件显示,默认显示为A组件,可以切换到B组件。问:

首次进入页面以及用is切换组件时,会触发A/B组件的哪些钩子函数,加了keep-alive后呢?

先给出结论

  1. 如果不加keep-alive,和正常的进入页面和离开页面触发的钩子函数一致。

  2. 如果加了keep-alive,组件首次加载才会触发 created,mounted等钩子函数,切换时就不会触发created,mounted,beforedestroyed等,所以额外加一个activated和deactivated钩子来提示页面已切换,离开当前页面,才会销毁A/B两个组件触发beforeDestroyed和destroyed两个钩子

这里面涉及到一些钩子函数的触发顺序问题,我们来写详细的demo来验证下








组件A.vue和B.vue代码基本一致,下面是A.vue示例,B.vue只是将A改为了B








keep-alive和非keep-alive钩子函数执行对比图

类别 非keep-alive keep-alive
首次进入 index beforeCreate

index created
index beforeMount
index mounted
index beforeUpdate
A beforeCreate
A created
A beforeMount
A mounted
index updated
| index beforeCreate
index created
index beforeMount
index mounted
index beforeUpdate
A beforeCreate
A created
A beforeMount
A mounted
A activated
index updated |
| 切换到B组件 | index beforeUpdate
A beforeDestroy
A destroyed
index updated
index beforeUpdate
B beforeCreate
B created
B beforeMount
B mounted
index updated | A deactivated
index updated
index beforeUpdate
B beforeCreate
B created
B beforeMount
B mounted
B activated
index updated |
| 再切回到A组件 | index beforeUpdate
A beforeCreate
A created
A beforeMount
B beforeDestroy
B destroyed
A mounted
index updated | index beforeUpdate
B deactivated
A activated
index updated |
| 再切回B组件 | index beforeUpdate
B beforeCreate
B created
B beforeMount
A beforeDestroy
A destroyed
B mounted
index updated | index beforeUpdate
A deactivated
B activated
index updated |
| 点击离开当前页面 | index beforeDestroy
B beforeDestroy
B destroyed
index destroyed | index beforeDestroy
B deactivated
A beforeDestroy
A destroyed
B beforeDestroy
B destroyed
index destroyed |

完整demo地址,参见 vue hooks demo | github(opens new window)

#component动态组件与使用v-if控制组件显示有什么区别

component动态组件可以理解为它就是v-if控制组件显示的语法糖。我们用一个例子来测试,之前我们写过compoennt用is切换时的钩子函数demo,我们把demo改写下,使用 v-if来替换component,对比页面渲染以及钩子函数的执行情况。改写如下:

实验证明,component和v-if的页面显示效果,钩子函数执行情况一模一样,不管是否加keep-alive,完整demo参见 component vs v-if | github(opens new window)

#element tabs组件切换页面时,显示或隐藏的组件会触发哪些钩子函数

这里主要是要弄清楚el-tabs是怎么实现组件切换的,使用el-tab-pane slot和不用这个插槽触发的钩子函数会相同吗?

如果不使用el-tab-pane的slot,只使用tab的导航,下面的内容自己控制,就看是使用component还是v-if来控制了,这种情况el-tabs组件不会干扰切换的逻辑。

问题来了,如果内容放到了el-tab-pane的slot呢?他有一个lazy属性,用来设置某个tab标签页是否延迟渲染,这个会对钩子函数有什么影响呢?我们改写上面的例子,通过demo来看看



  1. 如果默认lazy为false,且slot直接写对应的组件 首次进入会触发A/B/index三个组件的新进入页面相关钩子,再切换到B,不会触发A/B的任何钩子,仅触发index的updated相关钩子, 再切换到A或来回切换,同上不会触发A/B任何钩子,离开页面,触发A/B/index的destroy相关钩子
  2. 如果lazy设置为ture,且slot直接写对应的组件 首次进入会触发A/index两个组件的新进入页面相关钩子,再切换到B,不会触发A的任何钩子,仅触发index的updated相关钩子以及新进入页面B的钩子, 再切换到A或来回切换,不会触发A/B任何钩子,离开页面,触发A/B/index的destroy相关钩子
  3. 如果lazy为false,slot里对应的组件用keep-alive包裹 首次进入会触发A/B/index三个组件的新进入页面相关钩子,外加A/B的activated钩子;再切换到B,不会触发A/B的任何钩子,仅触发index的updated相关钩子, 再切换到A或来回切换,同上不会触发A/B任何钩子,离开页面,触发A/B/index的destroy相关钩子以及A/B的deactivated相关钩子
  4. 如果lazy为true,slot里对应的组件用keep-alive包裹 首次进入会触发A/index两个组件的新进入页面相关钩子,外加A的activated钩子;再切换到B,不会触发A的任何钩子,仅触发index的updated相关钩子以及新进入页面B的钩子外加B的activated钩子, 再切换到A或来回切换,不会触发A/B任何钩子,离开页面,触发A/B/index的destroy相关钩子
  5. 如果lazy默认为false或者true,给组件加上v-if(或者component)来控制组件显示 和不使用slot,单独在外部用v-if时的钩子保持一致
  6. 如果lazy默认为false或true,给组件加上v-if(或者component)来控制组件显示,再加上keep-alive 和不使用slot,单独在外部用v-if时的钩子并加上keep-avlie保持一致

综上,el-tabs组件el-tab-pane的slot有好多种情况,会比较混乱,对于需要精准控制tabs组件切换逻辑的场景,个人建议不要使用他的slot,除非你能很明确的知道其钩子函数的执行顺序与逻辑,最好还是仅使用其顶部tab,下面的主内容写在外部,不要写在el-tab-pane内部。其实主要区别是,如果slot里不用v-if控制,首次加载后,A/B来回切换不会触发A/B的任何钩子函数。完整demo参见 el-tabs 切换逻辑demo| github(opens new window)

#keep-alive组件切换显示时,avtivated和created同时触发的问题

这里分两种情况

#每次进入页面都需要刷新所有数据

要么去掉该组件的keep-alive,要么将进入页面请求数据的操作,全部放到activated,在created或mounted就不请求数据了。

#首次进入页面请求所有数据,再次切换到该页面时刷新部分数据

首次进入created,activated钩子函数都会触发,再次切换到该页面时只会触发activated钩子,且一般先触发created钩子,然后再触发activated钩子

所以,在这种情况下,可以把首次进入才需要请求的数据放到created里执行,把首次进入和再次切回页面都需要请求的数据放到activated里执行

另外也可以把所有请求操作都放到created,然后在activated里写再次进入页面时刷新部分数据的逻辑。这种情况,会出现有些接口在首次加载请求两次的问题,怎么解决这个问题呢?你可以在created里设置标记值 alreadyCreated 为true,当进入activated钩子时,只有alreadyCreated为false才执行刷新数据操作。true就不执行,并将alreadyCreated设置为false

参考:选项 / 生命周期钩子 activated | Vue.js(opens new window)

#2020/06/11 周四

#手动触发window resize事件,fix改变窗口大小后其他tab页echart图表显示异常的问题

在echarts图表组件里,有时候需要图表大小自适应浏览器窗口大小。这种情况echart宽高都会设定为100%,依赖父元素的宽高。然后再监听window的resize事件,当窗口大小改变后,重绘图表。那么问题来了

在单个页面里没有问题,但在tab组件切换时,各个tab页是懒加载(keep-alive)的状态,tab切换时,会有页面组件被隐藏,但由于是keep-alive的,所以不会触发beforeDesotry事件,也就是没有销毁组件只是隐藏了。

dom被隐藏后,window的resize事件没有被移除。导致在其他页面调整窗口大小,被隐藏的页面也会执行重绘操作,宽高都是百分比的,被隐藏后,宽高会异常,导致图表显示异常

除非每次切tab都重绘图表或者不使用keep-alive每次都刷新页面,才不会有问题。最后想了个方法,每次点击tab手动触发window的resize事件进行重绘,下面是手动触发window resize事件的方法

tabClick(e) {
  console.log('tab click', e)
  // 触发window的resize事件
  this.$nextTick(e => {
    let resizeEvent = document.createEvent('Event')
    resizeEvent.initEvent('resize', true, true)
    window.dispatchEvent(resizeEvent)
  })
}

为什么不在tab切换后的activated里重绘图表呢?由于有十几个图表,这就需要写十几个手动重绘的函数,在tabclick的事件里写是改动最小的

你可能感兴趣的:(2020-06 前端技术汇总)