DAMWIHNLFTM

DAMWIHNLFTM 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

DAMWIHNLFTM 收藏了文章 · 1月4日

10个很棒的 JavaScript 字符串技巧

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

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

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

我们称一个字符序列为字符串。这几乎是所有编程语言中都有的基本类型之一。这里跟大家展示关于 JS 字符串的10个很棒的技巧,你可能还不知道哦?

1.如何多次复制一个字符串

JS 字符串允许简单的重复,与纯手工复制字符串不同,我们可以使用字符串的repeat方法。

const laughing = '小智'.repeat(3)
consol.log(laughing) // "小智小智小智"

const eightBits = '1'.repeat(8)
console.log(eightBits) // "11111111"

2. 如何填充一个字符串到指定的长度

有时,我们希望字符串具有特定长度。 如果字符串太短,则需要填充剩余空间,直到达到指定的长度为止。

过去,主要还是使用库 left-pad。 但是,今天我们可以使用padStartSpadEnd方法,选择哪种方法取决于是在字符串的开头还是结尾填充字符串。

// 在开头添加 "0",直到字符串的长度为 8。
const eightBits = '001'.padStart(8, '0')
console.log(eightBits) // "00000001"

//在末尾添加“ *”,直到字符串的长度为5。
const anonymizedCode = "34".padEnd(5, "*")
console.log(anonymizedCode) // "34***"

3.如何将字符串拆分为字符数组

有多种方法可以将字符串分割成字符数组,我更喜欢使用扩展操作符(...):

const word = 'apple'
const characters = [...word]
console.log(characters) // ["a", "p", "p", "l", "e"]

注意,这并不总是像预期的那样工作。有关更多信息,请参见下一个技巧。

4.如何计算字符串中的字符

可以使用length属性。

const word = "apple";
console.log(word.length) // 5

但对于中文来说,这个方法就不太靠谱。

const word = "𩸽"
console.log(word.length) // 2

日本汉字𩸽返回length2,为什么? JS 将大多数字符表示为16位代码点。 但是,某些字符表示为两个(或更多)16 位代码点,称为代理对。 如果使用的是length属性,JS 告诉你使用了多少代码点。 因此,𩸽(hokke)由两个代码点组成,返回错误的值。

那怎么去判断呢,使用解构操作符号(...)

const word = "𩸽"
const characters = [...word]
console.log(characters.length) // 1

这种方法在大多数情况下都有效,但是有一些极端情况。 例如,如果使用表情符号,则有时此长度也是错误的。 如果真想计算字符正确长度,则必须将单词分解为 字素簇(Grapheme Clusters) ,这超出了本文的范围,这里就不在这说明。

5.如何反转字符串中的字符

反转字符串中的字符是很容易的。只需组合扩展操作符(...)、Array.reverse方法和Array.join方法。

const word = "apple"
const reversedWord = [...word].reverse().join("")
console.log(reversedWord) // "elppa"

和前面一样,也有一些边缘情况。遇到边缘的情况就有需要首先将单词拆分为字素簇

6. 如何将字符串中的第一个字母大写

一个非常常见的操作是将字符串的第一个字母大写。虽然许多编程语言都有一种本地方法来实现这一点,但 JS 需要做一些工作。

let word = 'apply'

word = word[0].toUpperCase() + word.substr(1)

console.log(word) // "Apple"

另一种方法:

// This shows an alternative way
let word = "apple";

// 使用扩展运算符(`...`)拆分为字符

const characters = [...word];
characters[0] = characters[0].toUpperCase();
word = characters.join("");

console.log(word); // "Apple"

7.如何在多个分隔符上分割字符串

假设我们要在分隔符上分割字符串,第一想到的就是使用split方法,这点,智米们肯定知道。 但是,有一点大家可能不知道,就是split可以同时拆分多个分隔符, 使用正则表达式就可以实现:

// 用逗号(,)和分号(;)分开。

const list = "apples,bananas;cherries"
const fruits = list.split(/[,;]/)
console.log(fruits); // ["apples", "bananas", "cherries"]

8.如何检查字符串是否包含特定序列

字符串搜索是一项常见的任务。 在 JS 中,你可以使用String.includes方法轻松完成此操作。 不需要正则表达式。

const text = "Hello, world! My name is Kai!"
console.log(text.includes("Kai")); // true

9.如何检查字符串是否以特定序列开头或结尾

在字符串的开头或结尾进行搜索,可以使用String.startsWithString.endsWith方法。

const text = "Hello, world! My name is Kai!"

console.log(text.startsWith("Hello")); // true

console.log(text.endsWith("world")); // false

10.如何替换所有出现的字符串

有多种方法可以替换所有出现的字符串。 可以使用String.replace方法和带有全局标志的正则表达式。 或者,可以使用新的String.replaceAll方法。 请注意,并非在所有浏览器和Node.js 版本中都可用此新方法。

const text = "I like apples. You like apples."

console.log(text.replace(/apples/g, "bananas"));
// "I like bananas. You like bananas."

console.log(text.replaceAll("apples", "bananas"));
// "I lik

总结

字符串是几乎所有编程语言中最基本的数据类型之一。同时,它也是新开发人员学习的最早的数据类型之一。然而,尤其是在JavaScript中,许多开发人员并不知道关于字符串的一些有趣的细节。希望此文对你有所帮助。

我是小智,我们下期见。


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

原文:https://dev.to/kais_blog/10-a...

交流

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

查看原文

DAMWIHNLFTM 收藏了文章 · 1月4日

web视频基础教程

前言

提到网页播放视频,大部分前端首先想到的肯定是:

<video width="600" controls>
  <source data-original="demo.mp4" type="video/mp4">
  <source data-original="demo.ogg" type="video/ogg">
  <source data-original="demo.webm" type="video/webm">
  您的浏览器不支持 video 标签。
</video>

的确,一个简单的video标签就可以轻松实现视频播放功能

但是,当视频的文件很大时,使用video的播放效果就不是很理想:

  1. 播放不流畅(尤其在:首次初始化视频 场景时卡顿非常明显)
  2. 浪费带宽,如果用户仅仅观看了一个视频的前几秒,可能已经被提前下载了几十兆流量了。即浪费了用户的流量,也浪费了服务器的昂贵带宽

理想状态下,我们希望的播放效果是:

  1. 边播放,边下载(渐进式下载),无需一次性下载视频(流媒体)
  2. 视频码率的无缝切换(DASH)
  3. 隐藏真实的视频访问地址,防止盗链和下载(Object URL)

在这种情况下,普通的video标签就无法满足需求了

206 状态码

<video width="600" controls>
  <source data-original="demo.mp4" type="video/mp4">
</video>

我们播放demo.mp4视频时,浏览器其实已经做过了部分优化,并不会等待视频全部下载完成后才开始播放,而是先请求部分数据

206

我们在请求头添加

Range: bytes=3145728-4194303

表示需要文件的第3145728字节到第4194303字节区间的数据

后端响应头返回

Content-Length: 1048576
Content-Range: bytes 3145728-4194303/25641810

Content-Range表示返回了文件的第3145728字节到第4194303字节区间的数据,请求文件的总大小是25641810字节
Content-Length表示这次请求返回了1048576字节(4194303 - 3145728 + 1)

断点续传和本文接下来将要介绍的视频分段下载,就需要使用这个状态码

Object URL

我们先来看看市面上各大视频网站是如何播放视频?

哔哩哔哩:
bili-v

腾讯视频:
ten-v

爱奇艺:
iqi-v

可以看到,上述网站的video标签指向的都是一个以blob开头的地址: blob:https://www.bilibili.com/0159a831-92c9-43d1-8979-fe42b40b0735,该地址有几个特点:

  1. 格式固定: blob:当前网站域名/一串字符
  2. 无法直接在浏览器地址栏访问
  3. 即使是同一个视频,每次新打开页面,生成的地址都不同

其实,这个地址是通过URL.createObjectURL生成的Object URL

const obj = {name: 'deepred'};
const blob = new Blob([JSON.stringify(obj)], {type : 'application/json'});
const objectURL = URL.createObjectURL(blob);

console.log(objectURL); // blob:https://anata.me/06624c66-be01-4ec5-a351-84d716eca7c0

createObjectURL接受一个FileBlob或者MediaSource对象作为参数,返回的ObjectURL就是这个对象的引用

Blob

Blob是一个由不可改变的原始数据组成的类似文件的对象;它们可以作为文本或二进制数据来读取,或者转换成一个ReadableStream以便用来用来处理数据

我们常用的File对象就是继承并拓展了Blob对象的能力

image.png

<input id="upload" type="file" />
const upload = document.querySelector("#upload");
const file = upload.files[0];

file instanceof File; // true
file instanceof Blob; // true
File.prototype instanceof Blob; // true

我们也可以创建一个自定义的blob对象

const obj = {hello: 'world'};
const blob = new Blob([JSON.stringify(obj, null, 2)], {type : 'application/json'});

blob.size; // 属性
blob.text().then(res => console.log(res)) // 方法

Object URL的应用

<input id="upload" type="file" />
<img id="preview" alt="预览" />
const upload = document.getElementById('upload');
const preview = document.getElementById("preview");

upload.addEventListener('change', () => {
  const file = upload.files[0];
  const src = URL.createObjectURL(file);
  preview.src = src;
});

createObjectURL返回的Object URL直接通过img进行加载,即可实现前端的图片预览功能

blob-pre

同理,如果我们用video加载Object URL,是不是就能播放视频了?

index.html

<video controls width="800"></video>

demo.js

function fetchVideo(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.responseType = 'blob'; // 文件类型设置成blob
    xhr.onload = function() {
      resolve(xhr.response);
    };
    xhr.onerror = function () {
      reject(xhr);
    };
    xhr.send();
  })
}

async function init() {
  const res = await fetchVideo('./demo.mp4');
  const url = URL.createObjectURL(res);
  document.querySelector('video').src = url;
}

init();

文件目录如下:

├── demo.mp4
├── index.html
├── demo.js

使用http-server简单启动一个静态服务器

npm i http-server -g

http-server -p 4444 -c-1

访问http://127.0.0.1:4444/,video标签的确能够正常播放视频,但我们使用ajax异步请求了全部的视频数据,这和直接使用video加载原始视频相比,并无优势

Media Source Extensions

结合前面介绍的206状态码,我们能不能通过ajax请求部分的视频片段(segments),先缓冲到video标签里,然后当视频即将播放结束前,继续下载部分视频,实现分段播放呢?

答案当然是肯定的,但是我们不能直接使用video加载原始分片数据,而是要通过 MediaSource API

需要注意的是,普通的mp4格式文件,是无法通过MediaSource进行加载的,需要我们使用一些转码工具,将普通的mp4转换成fmp4(Fragmented MP4)。为了简单演示,我们这里不使用实时转码,而是直接通过MP4Box工具,直接将一个完整的mp4转换成fmp4

#### 每4s分割1段
mp4box -dash 4000 demo.mp4

运行命令,会生成一个demo_dashinit.mp4视频文件和一个demo_dash.mpd配置文件。其中demo_dashinit.mp4就是被转码后的文件,这次我们可以使用MediaSource进行加载了

文件目录如下:

├── demo.mp4
├── demo_dashinit.mp4
├── demo_dash.mpd
├── index.html
├── demo.js

index.html

<video width="600" controls></video>

demo.js

class Demo {
  constructor() {
    this.video = document.querySelector('video');
    this.baseUrl = '/demo_dashinit.mp4';
    this.mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';

    this.mediaSource = null;
    this.sourceBuffer = null;

    this.init();
  }

  init = () => {
    if ('MediaSource' in window && MediaSource.isTypeSupported(this.mimeCodec)) {
      const mediaSource = new MediaSource();
      this.video.src = URL.createObjectURL(mediaSource); // 返回object url
      this.mediaSource = mediaSource;
      mediaSource.addEventListener('sourceopen', this.sourceOpen); // 监听sourceopen事件
    } else {
      console.error('不支持MediaSource');
    }
  }

  sourceOpen = async () => {
    const sourceBuffer = this.mediaSource.addSourceBuffer(this.mimeCodec); // 返回sourceBuffer
    this.sourceBuffer = sourceBuffer;
    const start = 0;
    const end = 1024 * 1024 * 5 - 1; // 加载视频开头的5M数据。如果你的视频文件很大,5M也许无法启动视频,可以适当改大点
    const range = `${start}-${end}`;
    const initData = await this.fetchVideo(range);
    this.sourceBuffer.appendBuffer(initData);

    this.sourceBuffer.addEventListener('updateend', this.updateFunct, false);
  }

  updateFunct = () => {
    
  }

  fetchVideo = (range) => {
    const url = this.baseUrl;
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open('GET', url);
      xhr.setRequestHeader("Range", "bytes=" + range); // 添加Range头
      xhr.responseType = 'arraybuffer';

      xhr.onload = function (e) {
        if (xhr.status >= 200 && xhr.status < 300) {
          return resolve(xhr.response);
        }
        return reject(xhr);
      };

      xhr.onerror = function () {
        reject(xhr);
      };
      xhr.send();
    })
  }
}

const demo = new Demo()

mse

实现原理:

  1. 通过请求头Range拉取数据
  2. 将数据喂给sourceBufferMediaSource对数据进行解码处理
  3. 通过video进行播放

我们这次只请求了视频的前5M数据,可以看到,视频能够成功播放几秒,然后画面就卡住了。

video-load

接下来我们要做的就是,监听视频的播放时间,如果缓冲数据即将不够时,就继续下载下一个5M数据

const isTimeEnough = () => {
  // 当前缓冲数据是否足够播放
  for (let i = 0; i < this.video.buffered.length; i++) {
    const bufferend = this.video.buffered.end(i);
    if (this.video.currentTime < bufferend && bufferend - this.video.currentTime >= 3) // 提前3s下载视频
      return true
  }
  return false
}

当然我们还有很多问题需要考虑,例如:

  1. 每次请求分段数据时,如何更新Range的请求范围
  2. 初次请求数据时,如何确保video有足够的数据能够播放视频
  3. 兼容性问题
  4. 更多细节。。。。

详细分段下载过程,见完整代码

流媒体协议

视频服务一般分为:

  1. 点播
  2. 直播

不同的服务,选择的流媒体协议也各不相同。主流的协议有: RTMP、HTTP-FLV、HLS、DASH、webRTC等等,详见《流媒体协议的认识》

我们之前的示例,其实就是使用的DASH协议进行的点播服务。还记得当初使用mp4box生成的demo_dash.mpd文件吗?mpd(Media Presentation Description)文件就存储了fmp4文件的各种信息,包括视频大小,分辨率,分段视频的码率。。。

b站的点播就是采用的DASH协议

HLS协议的m3u8索引文件就类似DASH的mpd描述文件

协议索引文件传输格式
DASHmpdm4s
HLSm3u8ts

开源库

我们之前使用原生Media Source手写的加载过程,其实市面上已经有了成熟的开源库可以拿来即用,例如:http-streaminghls.jsflv.js。同时搭配一些解码转码库,也可以很方便的在浏览器端进行文件的实时转码,例如mp4box.jsffmpeg.js

总结

本文简单介绍了 Media Source Extensions 实现视频渐进式播放的原理,涉及到基础的点播直播相关知识。由于音视频技术涉及的内容很多,加上本人水平的限制,所以只能帮助大家初步入个门而已

参考

查看原文

DAMWIHNLFTM 赞了文章 · 2020-12-07

我是程序员,我用这种方式铭记历史

抗战直播: 以图文方式“直播”1931年9月18日至1945年9月2日14年间抗战的日日夜夜

开源地址https://github.com/kokohuang/WarOfResistanceLive 欢迎 star 收藏一波

预览地址https://kokohuang.github.io/WarOfResistanceLive

前言

在目前浮躁的互联网环境下,做一件好事不难,难的是连续8年做一件有意义的事。

在微博上有这样一位博主,从2012年7月7日开始,截至到2020年9月2日,@抗战直播 以图文形式,记录了从1937年7月7日至1945年9月2日中华民族全面抗战的这段历史。2980 天,从未间断,平均每天 12 条,累计 35214 篇。

2020年9月18日7时零7分,沉寂了半个月的 @抗战直播 恢复更新,他们将继续以图文的形式记录1931年9月18日至1937年7月7日这六年的抗战历史。

下一个 6 年,他们已经在路上。

历史是不能被遗忘的。

作为程序员的我,在历史面前,我能做点什么?

除了敬佩 @抗战直播 这么多年来的坚持,我更想做一点自己力所能及且有意义的事情。

在得到博主 @抗战直播 的允许与支持后,于是就有了抗战直播这个项目的诞生。

抗战直播

抗战直播(https://kokohuang.github.io/WarOfResistanceLive)是一个以图文方式“直播”1931年9月18日至1945年9月2日14年间抗战历史的网站,每天会更新历史上的今天所发生的一些重大历史事件。通过这一条条图文抗战内容,让我们仿佛置身于那段从局部抗战到全面抗战,再从全面抗战到山河收复的那14年时光,与抗战中的国人一起经历每一个日日夜夜,感受他们曾经的屈辱与绝望,感受他们的光荣与梦想。

本项目主要由 Python 爬虫 + Hexo + Github Actions持续集成服务组成,开源在 GitHub 上,并且部署于 Github Pages。目前包含以下功能:

  • 每日定时自动同步更新数据
  • 查看博主目前所有的微博数据
  • 支持RSS订阅功能
  • 基于Github Actions的持续集成服务
  • ...

项目结构如下:

├── .github/workflows # 工作流配置文件
├── resources # 微博数据
├── site # 网站源码
└── spider # 微博爬虫

接下来,我将简单的给大家介绍该项目的一些核心逻辑与实现。

Python 爬虫

该项目使用的爬虫是基于 weibo-crawler 项目的简化及修改实现(仅供研究使用),感谢作者 dataabc

实现原理

  • 通过访问手机版的微博绕过其登录验证,可查看某个博主的大部分微博数据,如:https://m.weibo.cn/u/2896390104
  • 通过开发者工具查看得知,通过 json 接口 https://m.weibo.cn/api/container/getIndex 即可获取微博数据列表:

    def get_json(self, params):
        """获取网页中json数据"""
        url = 'https://m.weibo.cn/api/container/getIndex?'
        r = requests.get(url,
                         params=params,
                         headers=self.headers,
                         verify=False)
        return r.json()

如何使用

安装依赖:

pip3 install -r requirements.txt

使用:

python weibo.py

运行效果:

注意事项

  • 速度过快容易被系统限制:可通过加入随机等待逻辑,可降低被系统限制的风险;
  • 无法获取全部微博数据:可通过添加 cookie 逻辑获取全部数据;

更多内容可查看 weibo-crawler

Hexo

经过了一番的抉择,最终选择 Hexo + Next 主题作为本项目网站的框架。

Hexo 是一款基于 Node.js 的静态博客框架,依赖少易于安装使用,可以方便的生成静态网页托管在 GitHub Pages 上,还有丰富的主题可供挑选。关于如何安装使用 Hexo 可详细查看官方文档:https://hexo.io/zh-cn/docs/

那么,如何实现 RSS 订阅功能呢?

得益于 Hexo 丰富的插件功能,hexo-generator-feed 可以很方便的帮我们实现。

首先,在博客根目录下安装该插件:

$ npm install hexo-generator-feed --save

接着,在博客根目录下的 _config.yml 文件中添加相关配置:

feed:
  enable: true # 是否启用插件
  type: atom # Feed的类型,支持 atom 和 rss2,默认 atom
  path: atom.xml # 生成文件的路径
  limit: 30 # 生成最大文章数,如果为 0 或 false 则生成所有的文章
  content: true # 如果为 true 则展示文章所有内容
  content_limit: # 文章展示的内容长度,仅当 content 为 false 有效
  order_by: -date # 按照日期排序
  template: # 自定义模板路径

最后,在主题根目录下的 _config.yml 文件中添加 RSS 订阅入口:

menu:
  RSS: /atom.xml || fa fa-rss # atom.xml文件路径地址和图标设置

这样,我们就可以为自己的博客添加 RSS 订阅功能。WarOfResistanceLive 的订阅地址为:

https://kokohuang.github.io/WarOfResistanceLive/atom.xml

订阅效果如下图:

Github Actions 持续集成

Github Actions 是由 Github2018年10月 推出的持续集成服务,在此之前,我们可能更多的使用 Travis CI 来实现持续集成服务。以我个人的感觉来看,Github Actions 功能非常强大,比 Travis CI 的可玩性更高,Github Actions 拥有丰富的 action 市场,将这些 action 组合起来,我们就可以很简单的完成很多很有趣的事情。

我们先来看看Github Actions 的一些基本概念:

  • workflow:工作流程。即持续集成一次运行的过程。该文件存放于仓库的 .github/workflows 目录中,可包含多个;
  • job:任务。一个 workflow 可包含一个或多个 jobs,即代表一次集成的运行,可完成一个或多个任务;
  • step:步骤。一个 job 由多个 step 组成,即代表完成一个任务需要哪些步骤;
  • action:动作。每个 step 里面可包含一个或多个 action,即代表一个步骤内,可执行多个 action 动作。

了解了 Github Actions 的这些基本概念后,我们来看看 WarOfResistanceLive 的持续集成服务是怎样实现的,以下是本项目使用的 workflow 完整实现:

# workflow 的名称
name: Spider Bot

# 设置时区
env:
  TZ: Asia/Shanghai

# 设置工作流触发方式.
on:
  # 定时触发,在 8:00-24:00 间每隔 2 小时更新一次(https://crontab.guru)
  # 由于 cron 设定的时间为 UTC 时间,所以 +8 即为北京时间
  schedule:
    - cron: "0 0-16/2 * * *"

  # 允许手动触发 Actions
  workflow_dispatch:

jobs:
  build:
    # 使用 ubuntu-latest 作为运行环境
    runs-on: ubuntu-latest

    # 将要执行的任务序列
    steps:
      # 检出仓库
      - name: Checkout Repository
        uses: actions/checkout@v2

      # 设置 Python 环境
      - name: Setup Python
        uses: actions/setup-python@v2
        with:
          python-version: "3.x"

      # 缓存 pip 依赖
      - name: Cache Pip Dependencies
        id: pip-cache
        uses: actions/cache@v2
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('./spider/requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-
      
      # 安装 pip 依赖
      - name: Install Pip Dependencies
        working-directory: ./spider
        run: |
          python -m pip install --upgrade pip
          pip install flake8 pytest
          if [ -f requirements.txt ]; then pip install -r requirements.txt; fi

      # 运行爬虫脚本
      - name: Run Spider Bot
        working-directory: ./spider  # 指定工作目录,仅对 run 命令生效
        run: python weibo.py

      # 获取系统当前时间
      - name: Get Current Date
        id: date
        run: echo "::set-output name=date::$(date +'%Y-%m-%d %H:%M')"

      # 提交修改
      - name: Commit Changes
        uses: EndBug/add-and-commit@v5
        with:
          author_name: Koko Huang
          author_email: huangjianke@vip.163.com
          message: "已同步最新数据(${{steps.date.outputs.date}})"
          add: "./"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # 推送远端
      - name: Push Changes
        uses: ad-m/github-push-action@master
        with:
          branch: main
          github_token: ${{ secrets.GITHUB_TOKEN }}

      # 设置 Node.js 环境
      - name: Use Node.js 12.x
        uses: actions/setup-node@v1
        with:
          node-version: "12.x"

      # 缓存 NPM 依赖
      - name: Cache NPM Dependencies
        id: npm-cache
        uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('./site/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      # 安装 NPM 依赖
      - name: Install NPM Dependencies
        working-directory: ./site
        run: npm install

      # 构建 Hexo
      - name: Build Hexo
        working-directory: ./site # 指定工作目录,仅对 run 命令生效
        run: npm run build

      # 发布 Github Pages
      - name: Deploy Github Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./site/public # 指定待发布的路径地址
          publish_branch: gh-pages # 指定远程分支名称

执行效果图如下:

workflow 文件的配置字段非常多,配置文件中也给出了详细的注释。接下来,我们主要看下以下几个比较重要的配置:

工作流的触发方式

# 设置工作流触发方式.
on:
  # 定时触发,在 8:00-24:00 间每隔 2 小时更新一次(https://crontab.guru)
  # 由于 cron 设定的时间为 UTC 时间,所以 +8 即为北京时间
  schedule:
    - cron: "0 0-16/2 * * *"

  # 允许手动触发工作流程
  workflow_dispatch:

我们可以使用 on 工作流程语法配置工作流程为一个或多个事件运行。支持自动与手动两种方式触发。schedule 事件允许我们在计划的时间触发工作流程,我们可以使用 POSIX cron 语法 来安排工作流程在特定的时间运行。

计划任务语法有五个字段,中间用空格分隔,每个字段代表一个时间单位:

┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of the month (1 - 31)
│ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
│ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
│ │ │ │ │                                   
│ │ │ │ │
│ │ │ │ │
* * * * *

我们还可在这五个字段中使用以下运算符:

运算符描述示例
*任意值 * 在每天的每分钟运行
,值列表分隔符2,10 4,5 * 在每天第 4 和第 5 小时的第 2 和第 10 分钟运行
-值的范围0 4-6 * 在第 4、5、6 小时的第 0 分钟运行
/步骤值20/15 从第 20 分钟到第 59 分钟每隔 15 分钟运行(第 20、35 和 50 分钟)

我们可以使用 https://crontab.guru 来生成计划任务语法,你也可以查看更多的 crontab guru 示例

另外,我们还可以通过配置 workflow_dispatchrepository_dispatch字段来实现手动触发工作流程。

on 字段也可以配置为 push,即仓库有 push 操作时则触发工作流的执行,详细的触发工作流配置可以查看 配置工作流程事件

步骤序列

从配置文件中我们可以看到,该项目的一次持续集成的运行包含了以下步骤:

检出仓库 --> 设置 Python 环境 --> 缓存 pip 依赖 --> 安装 pip 依赖 --> 运行爬虫脚本 --> 获取当前时间 --> 提交修改 --> 推送远端 --> 设置 Node.js 环境 --> 缓存 NPM 依赖 --> 安装 NPM 依赖 --> 构建 Hexo --> 发布 Github Pages

本项目的 workflow 主要有以下几个要点:

  • 运行环境:整个工作流运行在虚拟环境 ubuntu-latest。还可以指定其他虚拟环境,如 Windows ServermacOS 等;
  • 缓存依赖:通过对依赖的缓存,可提升安装相关依赖的速度。具体使用可查看:缓存依赖项以加快工作流程
  • 获取当前时间:后续提交修改步骤中的 commit message 中使用到了该步骤中获取到当前时间,这里就使用到了 step 上下文 的相关概念,我们可以为 step 指定一个 id,后续 step 中我们就可以通过 steps.<step id>.outputs 来获取已经运行的步骤相关信息;
  • 构建 Hexo:即执行 hexo generate 命令生成静态网页;
  • 工作流程中的身份验证:提交推送及发布步骤需要进行身份验证。GitHub 提供一个令牌,可用于代表 GitHub Actions 进行身份验证。我们所需要做的就是创建一个命名为 GITHUB_TOKEN 的令牌。具体步骤如下:Settings --> Developer settings --> Personal access tokens --> Generate new token,命名为 GITHUB_TOKEN ,并勾选中你所需要的的权限,然后就可以在 step 中通过使用 ${{ secrets.GITHUB_TOKEN }} 进行身份验证。

更多 Action 可在 Github官方市场 查看。

结语

截止今天,抗战直播收录了从 2012年7月7日 至今的35000+条博文,我相信,这些珍贵的历史数据,一定会不断丰富着我们对抗战历史认识,更会让我们深入地了解了中华民族的民族性。国家虽乱,民族性犹在。

就如 @抗战直播 的发起人所说:“我想亲身体验这8年,感受他们曾经的屈辱、绝望、光荣与梦想。”。也希望你能通过本项目,静静的去感受那一段抗战时光。

最后,引用博主 @抗战直播 的一段话:

“我们直播抗战,并非为了鼓动仇恨等负面的情绪,而是想适度唤起遗忘,当我们时刻牢记祖辈们蒙受的苦难、恐惧和屈辱时;当我们体味祖辈们是如何在国家民族危亡之际抛弃前嫌,实现民族和解时,当我们目睹着祖辈们是如何从容慷慨的走向死亡,以身体为这个国家献祭之时,相信我们对于现实将有更加成熟和理性的思考。”

铭记历史,砥砺奋进。勿忘国耻,吾辈自强。

查看原文

赞 10 收藏 2 评论 0

DAMWIHNLFTM 赞了文章 · 2020-11-13

常见登录鉴权方案

编者注:今天我们分享的是卢士杰同学整理的网站常用鉴权方案的实现原理与实现以及他们的适用场景,帮助大家在业务中做合适的选择。

背景

说起鉴权大家应该都很熟悉,不过作为前端开发来讲,鉴权的流程大头都在后端小哥那边,本文的目的就是为了让大家了解一下常见的鉴权的方式和原理。

认知:HTTP 是一个无状态协议,所以客户端每次发出请求时,下一次请求无法得知上一次请求所包含的状态数据。

一、HTTP Auth Authentication

简介

HTTP 提供一个用于权限控制和认证的通用框架。最常用的HTTP认证方案是HTTP Basic Authentication

鉴权流程

加解密过程

// Authorization 加密过程
let email = "postmail@test.com"
let password = "12345678"
let auth = `${email}:${password}`
const buf = Buffer.from(auth, 'ascii');
console.info(buf.toString('base64')); // cG9zdG1haWxAdGVzdC5jb206MTIzNDU2Nzg=

// Authorization 解密过程
const buf = Buffer.from(authorization.split(' ')[1] || ''),  'base64');
const user = buf.toString('ascii').split(':');

其他 HTTP 认证

通用 HTTP 身份验证框架有多个验证方案使用。不同的验证方案会在安全强度上有所不同。

IANA 维护了一系列的验证方案,除此之外还有其他类型的验证方案由虚拟主机服务提供,例如 Amazon AWS ,常见的验证方案包括:

  • Basic (查看 RFC 7617, Base64 编码凭证. 详情请参阅下文.),
  • Bearer (查看 RFC 6750, bearer 令牌通过OAuth 2.0保护资源),
  • Digest (查看 RFC 7616, 只有 md5 散列 在Firefox中支持, 查看 bug 472823 用于SHA加密支持),
  • HOBA (查看 RFC 7486 (草案), HTTP Origin-Bound 认证, 基于数字签名),
  • Mutual (查看 draft-ietf-httpauth-mutual),
  • AWS4-HMAC-SHA256 (查看 AWS docs)

二、Cookie + Session

注册流程

思考:为什么要在密码里加点“盐”?

鉴权流程

Session 存储

最常用的 Session 存储方式是 KV 存储,如Redis,在分布式、API 支持、性能方面都是比较好的,除此之外还有 mysql、file 存储。

如果服务是分布式的,使用 file 存储,多个服务间存在同步 session 的问题;高并发情况下错误读写锁的控制。

Session Refresh

我们上面提到的流程中,缺少 Session 的刷新的环节,我们不能在用户登录之后经过一个 expires 时间就把用户踢出去,如果在 Session 有效期间用户一直在操作,这时候 expires 时间就应该刷新。

以 Koa 为例,刷新 Session 的机制也比较简单:
开发一个 middleware(默认情况下所有请求都会经过该 middleware),如果校验 Session 有效,就更新 Session 的 expires: 当前时间+过期时间。

优化:

  1. 频繁更新 session 会影响性能,可以在 session 快过期的时候再更新过期时间。
  2. 如果某个用户一直在操作,同一个 sessionID 可能会长期有效,如果相关 cookie 泄露,可能导致比较大的风险,可以在生成 sessionID 的同时生成一个 refreshID,在 sessionID 过期之后使用 refreshID 请求服务端生成新的 sessionID(这个方案需要前端判断 sessionID 失效,并携带 refreshID 发请求)。

单设备登录

有些情况下,只允许一个帐号在一个端下登录,如果换了一个端,需要把之前登录的端踢下线(默认情况下,同一个帐号可以在不同的端下同时登录的)。

这时候可以借助一个服务保存用户唯一标识和 sessionId 值的对应关系,如果同一个用户,但 sessionId 不一样,则不允许登录或者把之前的踢下线(删除旧 session )。

三、JWT

简介

JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。

JWT 组成

JWT 由三部分组成,分别是 header(头部),payload(载荷),signature(签证) 这三部分以小数点连接起来。

例如使用名为 jwt-token 的cookie来存储 JWT 例如:

jwt-token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoibHVzaGlqaWUiLCJpYXQiOjE1MzI1OTUyNTUsImV4cCI6MTUzMjU5NTI3MH0.WZ9_poToN9llFFUfkswcpTljRDjF4JfZcmqYS0JcKO8;

使用.分割值可以得到三部分组成元素,按照顺序分别为:

  • header

    • 值:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    • Base64 解码: {"alg": "HS256", "type": "JWT"}
  • payload

    • 值:eyJuYW1lIjoibHVzaGlqaWUiLCJpYXQiOjE1MzI1OTUyNTUsImV4cCI6MTUzMjU5NTI3MH0
    • Base64 解码:

      {
        "name": "lushijie", 
        "iat": 1532595255, // 发布时间
        "exp": 1532595270 // 过期时间
      }
  • signature

    • 值:WZ9_poToN9llFFUfkswcpTljRDjF4JfZcmqYS0JcKO8
    • 解码:

      const headerEncode = base64Encode(header);
      const payloadEncode = base64Encode(payload);
      let signature = HMACSHA256(headerEncode + '.' + payloadEncode, '密钥');

鉴权流程

Token 校验

对于验证一个 JWT 是否有效也是比较简单的,服务端根据前面介绍的计算方法计算出 signature,和要校验的JWT中的 signature 部分进行对比就可以了,如果 signature 部分相等则是一个有效的 JWT。

Token Refresh

为了减少 JWT Token 泄露风险,一般有效期会设置的比较短。 这样就会存在 JWT Token 过期的情况,我们不可能让用户频繁去登录获取新的 JWT Token。

解决方案:

可以同时生成 JWT Token 与 Refresh Token,其中 Refresh Roken 的有效时间长于 JWT Token,这样当 JWT Token 过期之后,使用 Refresh Token 获取新的 JWT Token 与 Refresh Token,其中 Refresh Token 只能使用一次。

四、OAuth

简介

有时候,我们登录某个网站,但我们又不想注册该网站的账号,这时我们可以使用第三方账号登录,比如 github、微博、微信、QQ等。

开放授权(OAuth)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。

OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。

OAuth是OpenID的一个补充,但是完全不同的服务。

—— 摘自 维基百科

授权流程

名词解释:

  • Third-party application:第三方应用程序又称"客户端"(client),比如打开知乎,使用第三方登录,选择 Github 登录,这时候知乎就是客户端。
  • Resource Owner:资源所有者,本文中又称"用户"(user),即登录用户。
  • Authorization server:认证服务器,即 Github 专门用来处理认证的服务器。
  • Resource server:资源服务器,即 Github 存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

  • A. A网站让用户跳转到 GitHub,请求授权码;GitHub 要求用户登录,然后询问“知乎网站要求获得 xx 权限,你是否同意?”;
  • B. 用户同意,GitHub 就会重定向回 A 网站,同时发回一个授权码;
  • C. A 网站使用授权码,向 GitHub 请求令牌;
  • D. GitHub 返回令牌;
  • E. A 网站使用令牌,向 GitHub 请求用户数据;

其他授权模式

授权码模式(authorization code)是功能最完整、流程最严密的授权模式。除了我们上面所说的授权码模式,其实还有其他授权模式:

  1. 简化模式(Implicit grant type)
    有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤
  2. 密码模式(Resource Owner Password Credentials Grant)
    如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌
  3. 客户端模式(Client Credentials Grant)
    适用于没有前端的命令行应用,即在命令行下请求令牌

关于这些模式详细请见:OAuth2.0 的四种方式

单点登录

单点登录(Single Sign On, SSO),即:单一标记(单点)登录。例如:QQ,我在QQ空间登录一次,我可以去访问QQ产品的其他服务:QQ邮箱、腾讯新闻等,都能保证你的账户保持登录状态。

延伸阅读:

五、总结对比

没有最好,只有最合适!!!

  • HTTP Auth Authentication:

    • 梳理总结:
      通用 HTTP 身份验证框架有多个验证方案使用。不同的验证方案会在安全强度上有所不同。HTTP Auth Authentication 是最常用的 HTTP认证方案,为了减少泄露风险一般要求 HTTPS 协议。
    • 适用场景:
      一般多被用在内部安全性要求不高的的系统上,如路由器网页管理接口
    • 问题:

      1. 请求上携带验证信息,容易被嗅探到
      2. 无法注销
  • Cookie + Session:

    • 梳理总结:

      • 服务端存储 session ,客户端存储 cookie,其中 cookie 保存的为 sessionID
      • 可以灵活 revoke 权限,更新信息后可以方便的同步 session 中相应内容
      • 分布式 session 一般使用 redis(或其他KV) 存储
    • 使用场景:
      适合传统系统独立鉴权
  • JWT:

    • 梳理总结:

      • 服务器不再需要存储 session,服务器认证鉴权业务可以方便扩展
      • JWT 并不依赖 cookie,也可以使用 header 传递
      • 为减少盗用,要使用 HTTPS 协议传输
    • 适用场景:

      • 适合做简单的 RESTful API 认证
      • 适合一次性验证,例如注册激活链接
    • 问题:

      1. 使用过程中无法废弃某个 token,有效期内 token 一直有效
      2. payload 信息更新时,已下发的 token 无法同步
  • OAuth:

    • 梳理总结:

      • OAuth是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。
      • GitHub OAuth 文档 Identifying and authorizing users for GitHub Apps
    • 适用场景:
      OAuth 分为下面四种模式

      1. 简化模式,不安全,适用于纯静态页面应用
      2. 授权码模式,功能最完整、流程最严密的授权模式,通常使用在公网的开放平台中
      3. 密码模式,一般在内部系统中使用,调用者是以用户为单位。
      4. 客户端模式,一般在内部系统之间的 API 调用。两个平台之间调用,以平台为单位。
查看原文

赞 2 收藏 1 评论 0

DAMWIHNLFTM 赞了文章 · 2020-11-04

28 个提升开发幸福度的 VsCode 插件

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

双 1111 拼团低至 85 元,来一起拼团学习搭建自己的博客吧

服务器如何搭建博客

1. Quokka.js

Quokka.js 是一个用于 JavaScript 和 TypeScript 的实时运行代码平台。这意味着它会实时运行你输入后的代码,并在编辑器中显示各种执行结果,建议亲自尝试一下。

图片描述

安装此扩展后,可以按Ctrl / Cmd(⌘)+ Shift + P显示编辑器的命令选项板,然后键入 Quokka 以查看可用命令的列表。选择并运行 “New JavaScript File”命令。你也可以按(⌘+ K + J)直接打开文件。在此文件中输入的任何内容都会立即执行。

图片描述

Quokka.js类似的扩展 –

  • Code Runner – 支持多种语言,如C,C ++,Java,JavaScript,PHP,Python,Perl,Perl 6等。
  • Runner

2. 括号配对着色(Bracket Pair Colorizer) 和 彩虹缩进(Indent Rainbow)

花括号和圆括号是许多编程语言不可分割的部分,在 JavaScript 等语言中,在一屏代码中花括号和园括号可能有多层嵌套,有些括号不太容易识别哪个对应哪个,然而却没有简单的方法来识别这些括号前后的对应关系。

括号配对着色(Bracket Pair Colorizer)彩虹缩进(Indent Rainbow)。这是两个不同的扩展。然而,他们就像是一对情侣,可以完美的配合使用。这些扩展将为你的编辑器添加一系列颜色,并使代码块易于辨别,一旦你习惯了它们,如果 VSCode 没有它们就会让人觉得很平淡。

不使用括号配对着色(Bracket Pair Colorizer) 和 彩虹缩进(Indent Rainbow)

不使用括号配对着色(Bracket Pair Colorizer) 和 彩虹缩进(Indent Rainbow)

使用括号配对着色(Bracket Pair Colorizer) 和 彩虹缩进(Indent Rainbow)后

使用括号配对着色(Bracket Pair Colorizer) 和 彩虹缩进(Indent Rainbow)后

3. snippets(代码片段)

代码片段是编辑器中的短代码。因此,可以输入 imr 并按Tab 来展开该代码片段,而不是'import React from '。类似地,clg 变成了 console.log。

各种各样的框架和类库都有很多代码片段:Javascript,React,Redux,Angular,Vue,Jest。 我个人认为 Javascript 代码片段非常有用,因为我主要使用 JS 。

一些很好的代码片段扩展 –

4. TODO高亮

通常在进行编码时,你认为可能有更好的方法来执行相同的操作。这时你留下注释// TODO: 需要重构 或其他相关的东西。但是你很容易忘记了这个注释,并将你的代码推送到主版本库(master) 或者生产环境(production)。 但是你如果使用 Todo Highlighter(高亮),它会高亮的显示并让你容易看到这个注释。

它以明亮的颜色突出代码中的 “TODO/FIXME” 或代码任何其他注释,以便始终清晰可见。另外还有一个很好的功能是 List Highlighted annotations ,它会在控制台中列出了所有 TODO。

图片描述

使用 Todo Highlighter(高亮)类似的扩展 –

  • Todo+ —  更强大的 Todo 高亮扩展,具有更多功能。
  • Todo Parser

5. Import Cost

扩展允许您查看导入模块的大小,它对 Webpack 中的 bundlers 有很大帮助,你可以查看是导入整个库还是只导入特定的实用程序。

图片描述

6. REST Client

作为 web 开发人员,我们经常需要使用 REST api。为了检查url和检查响应,使用了 Postman 之类的工具。但是,既然编辑器可以轻松地完成相同的任务,为什么还要使用不同的应用程序呢? REST Client 它允许你发送 HTTP 请求并直接在 Visual Studio 代码中查看响应。

图片描述

7. 自动闭合标记(Auto Close Tag)和自动重命名标记(Auto Rename Tag)

自从React的出现以及它在过去几年获得的吸引力以来,以 JSX 形式出现的类似 html 的语法现在非常流行。我们还必须使用 JavaScript 标签进行编码。任何web开发人员都会告诉你,输入标签是一件痛苦的事情。在大多数情况下,我们需要一个能够快速、轻松地生成标签及其子标签的工具。Emmet 是 VSCode 中一个很好的例子,然而,有时候,你只是想要一些简单明了的东西。例如自动更新标签,它在你输入开始标签时自动生成结束标签。当你更改相同的标签时,关闭标记会自动更改,这两个扩展就是这样做的。

它还适用于JSX和许多其他语言,如XML,PHP,Vue,JavaScript,TypeScript,TSX。

在这里获取这两个扩展 – 自动闭合标记(Auto Close Tag)自动重命名标记(Auto Rename Tag)

Auto Rename Tag

Auto Close Tag

类似的扩展 –

8. GitLens

正如其作者所说,GitLens 增强了 Visual Studio Code 中内置的 Git 功能,它包含了许多强大的功能,例如通过跟踪代码显示的代码作者,提交搜索,历史记录和GitLens资源管理器。你可以在此处阅读这些功能的完整说明。

图片描述

类似的扩展 –

9. Git项目管理器(Git Project Manager,GPM)

Git项目管理器(Git Project Manager,GPM)允许你直接从 VSCode 窗口打开一个针对Git存储库的新窗口。 基本上,你可以打开另一个存储库而无需离开VSCode。

安装此扩展后,您必须将 gitProjectManager.baseProjectsFolders 设置为包含 repos 的URL列表。例如:

{
    "gitProjectManager.baseProjectsFolders": [
        "/home/user/nodeProjects",
        "/home/user/personal/pocs"
    ]
} 

图片描述

类似的扩展 –

Project Manager – 我没有亲自使用它,但它有百万+安装。所以建议你一定要看一下。

10. Indenticator(缩进指示器)

在视觉上突出显示当前的缩进个数,因此,你可以轻松区分在不同级别缩进的各种代码块。

图片描述

11. VSCode Icons

使您的编辑更具吸引力的图标!

图片描述

类似的扩展 –

12. Dracula (Theme)

Dracula 是我最喜欢的主题。

图片描述

我们可以使用快捷键来快速的选择更换主题;

首先:按下 Ctrl + k

然后再按下:Ctrl + t

13. 其它推荐

  • Fira Code — 带编程连体字的等宽字体。 愚人码头注:clone 项目后,找到 ttf 文件夹,然后安装该文件夹中的字体文件。重新启动 VSCode ,选择TOOLS -> Options -> Fonts and Colors ,选择 Fira Code 即可。
  • Live Server — 一个具有静态和动态页面的实时重新加载功能的本地开发服务器。
  • EditorConfig for VS Code – 此插件尝试使用.editorconfig文件中的设置覆盖用户/工作区设置,不需要其他或特定于 vscode 的文件。与任何EditorConfig插件一样,如果未指定root = true,EditorConfig将继续在项目外部查找.editorconfig文件。
  • Prettier for VSCode — 一个代码格式化工具。
  • Bookmarks – 它可以帮助您在代码中导航,轻松快速地在重要位置之间移动。不再需要搜索代码,它还支持一组选择命令,允许您选择书签线和书签线之间的区域,它对日志文件分析非常有用。
  • Path Intellisense — Visual Studio Code插件,可自动填充文件名。
  • Version Lens — 在Visual Studio代码编辑器中显示npm,jspm,bower,dub和dotnet核心的软件包版本信息。

14. Material Theme & Icons

这是 VS Code 主题中的重要角色。 作者认为重要的主题是在编辑器中用笔和纸书写最接近的东西(特别是在使用无对比变体主题时)。 从集成的工具到文本编辑器,你的编辑器看起来几乎是平的和无缝的。

想象一个史诗般的主题加上史诗般的图标。 Material Theme Icons 是替换默认 VSCode 图标的绝佳选择。设计的大型图标目录与主题融为一体,使其更加美观,这有助于你在资源管理器中轻松找到你的文件。

clipboard.png

15. 具有居中布局的禅模式或者勿扰模式 (Zen Mode)

为了让广大苦逼码农能够在 coding/docing 时有清晰的思路,代表最广大码农利益的 VSCode 也加入了“禅模式”。该模式可以在你在页面编辑文件时启用,效果是全屏化你的编辑框,然后带有若隐若现的云雾效果。

打开方式:文件 > 首选项 > 设置 > 用户设置 > 工作台 > 禅模式

clipboard.png

16. 具有连字的字体

文字的风格使阅读变得简单方便,你可以使用好看连字的字体使编辑器看起来更友好。 这里是支持连字的6种最佳字体 (根据www.slant.co)

你可以尝试 Fira Code,它非常棒而且是开源的。 以下是引入 Fira Code 后在 VSCode 辊更改该字体的方法。

"editor.fontFamily": "Fira Code",
"editor.fontLigatures": true

具体使用方法可以参考:

vscode中修改字体,使用 Fira Code

提高visual studio使用逼格的连体字(Fira code)以及多行编辑(MixEdit)

17. 彩虹缩进 (indent-rainbow)

缩进风格,这个扩展为文本前面的缩进着色,在每个步骤中交替使用四种不同的颜色。

当然如果需要自定义自己喜欢的颜色,请将以下代码段复制并粘贴到 settings.json

"indentRainbow.colors": [
"rgba(16,16,16,0.1)",
"rgba(16,16,16,0.2)",
"rgba(16,16,16,0.3)",
"rgba(16,16,16,0.4)",
"rgba(16,16,16,0.5)",
"rgba(16,16,16,0.6)",
"rgba(16,16,16,0.7)",
"rgba(16,16,16,0.8)",
"rgba(16,16,16,0.9)",
"rgba(16,16,16,1.0)"
],

18. 自定义标题栏

这是一个很棒的视觉调整,改变了不同项目的标题栏颜色,以便轻松识别它们。 如果你处理可能具有相同代码或文件名的应用程序(例如react-native 应用程序和 React Web应用程序),这非常有用

设置方式:打开方式:文件 > 首选项 > 设置 > 工作区设置

19. Tag Wrapping

如果你不认识 Emmet,那么你可能是一个喜欢打字的人。Emmet 允许你写入缩写代码并返回的相应标记,目前 VSCode 已经内置,所以不用配置了。

如果你想了解更多的 Emmet 的简写,可以查看 Emmet Cheatsheet

20. 内外平衡

这条建议来自 https://vscodecandothat.com/,作者非常推荐它。

你可以使用 balance inwardbalance outward 的 Emmet 命令在 VS 代码中选择整个标记。 将这些命令绑定到键盘快捷键是有帮助的,例如 Ctrl + Shift + 向上箭头用于平衡向外,而 Ctrl + Shift +向下箭头 用于平衡向内。

21. Turbo Console.log()

没有人喜欢输入非常长的语句,比如 console.log()。这真的很烦人,尤其是当你只想快速输出一些东西,查看它的值,然后继续编码的时候。如果我告诉你,你可以像 Lucky Luke一样快速地控制台记录任何东西呢?

这是通过名为 Turbo Console Log 的扩展来完成的。它支持对下面一行中的任何变量进行日志记录,并在代码结构之后自动添加前缀。你还可以 取消注释/注释 alt+shift+u / alt+shift+c 为所有由这个扩展添加的 console.log()

此外,你也可以通过 alt+shift+d 删除所有:

22. Live server

这是一个非常棒的扩展,可以帮助你启动一个本地开发服务器,为静态和动态页面提供实时重新加载功能,它对 HTTPS、CORS、自定义本地主机地址和端口等主要特性提供了强大的支持。

如果与 VSCode LiveShare 一起使用,它甚至可以让你共享本地主机。

23. 使用多个游标 复制/粘贴

Mac: opt+cmd+up or opt+cmd+down

Windows: ctrl+alt+up or ctrl+alt+down

Linux: alt+shift+up or alt+shift+down

24. Breadcrumbs(面包屑)

编辑器的内容上方现在有一个被称为 Breadcrumbs 的导航栏,它显示你的当前位置,并允许在符号和文件之间快速导航。要使用该功能,可使用 View > Toggle Breadcrumbs 命令或通过 breadcrumbs.enabled 设置启用。要与其交互,请使用 Focus Breadcrumbs 命令或按 Ctrl + Shift +

25. Code CLI

代码有一个强大的命令行界面,允许你控制如何启动编辑器。你可以通过命令行选项打开文件、安装扩展名、更改显示语言和输出诊断信息。

想象一下,你通过 git clone <repo-url> 克隆一个远程库,你想要替换你正在使用的当前 VS Code实例。 通过命令 code . -r 将在不必离开 CLI 界面的情况下完成这一操作 (在此处了解更多信息)。

26. Polacode


你经常会看到带有定制字体和主题的代码截屏,如下所示。这是在VS代码与 x 扩展

我知道 Carbon 也是一种更好,更可定制的替代品。 但是,Polacode 允许你保留在代码编辑器中并使用你可能已购买的任何专用字体,这些字体在 Carbon 中无法使用。

27. Quokka (JS/TS ScratchPad)

Quokka 是J avaScript 和 TypeScript 的快速原型开发平台。在你输入代码时,它将立即运行你的代码,并在代码编辑器中显示各种执行结果。

Quokka 的一个很棒的扩展插件,当你准备技术面试时,你可以输出每个步骤,而不必在调试器中设置断点。它还可以帮助您在实际使用之前研究库的函数,如 Lodash 或 MomentJS,它甚至可以用于异步调用。

28. WakaTime

如果你想记录每天编程所花的时间,WakaTime 是一个扩展,它可以帮助记录和存储有关编程活动的指标和分析。


交流

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

clipboard.png

查看原文

赞 30 收藏 20 评论 2

DAMWIHNLFTM 收藏了文章 · 2020-10-30

不要再复制了,url 空白编码引发的事故!!!

最近在联调接口时发现了一个要命的问题,就是接口url明明是正确的,但是发送就是不成功,报404异常的错误

代码

伪代码如下,一个很正常的删除数据请求

function foo(id) {
  return axios({
    method: 'delete',
    url: '​/api​/resume​/queue​/' + id
  })
}

报错

报错如下:
image.png

这里和后端一起验证过了,确实有这个接口,使用postman请求是没问题的

查资料,反复验证

然后自己写了个接口模拟了下,没有重现这个问题,请求其它 api 也都正常

接着就是查各种资料,刚开始一度以为是 delete 请求、vue-cli的锅,查了各种资料,但还是一无所获

沉思

最后看着 DELETE http://localhost:8070/%E2%80%8B/api%E2%80%8B/resume%E2%80%8B/queue%E2%80%8B/81862404 404 (Not Found) 这一段报错信息陷入了沉思

404请求肯定是接口地址不正确的问题,但是这里的路径看着没问题(但也只是看着)

这里发现最后请求的路径加入了一些编码,也就是 %E2%80%8B 这个,然后在控制台调用 decodeURI('%E2%80%8B') ,返回 '' 空字符串,看到这里应该明白了,嗯,倒吸一口凉气...

原因

如果将光标移动到 上面 url 路径上,会发现每个/前面需要移动两次才能跳过,其实那儿确实有一个特殊的字符,只是它没有宽度,让人误以为是什么也没有,但是如果 encodeURI 编码一下会发现确实是有东西的,也就是 %E2%80%8B

最后发现,还是因为偷懒图方便,接口地址直接从 swagger 文档那边复制过来的,而复制源里面的字符存在空白编码%E2%80%8B,空白编码没有宽度,虽然看不到但是会导致无法精确匹配出现问题,浏览器对请求路径会自动编码,这样路径完全就不一样了,这个后端路由无法识别,当然就404了

网上查了下,%E2%80%8E 的学名叫 Zero-width-space(零宽空格) ,顾名思义,它是一个Unicode字符,肉眼不可见,却是确实存在的一个字符

解决

  • 一时复制一时爽,一直复制一直爽,你无法确认你复制的字符是否存在空白编码,所以少点复制,还是老老实实手敲一遍吧,尤其是字符串的部分
  • 从开发者的角度来看,能通过代码解决的肯定更好,其实可以在发送请求之前对 url 做个正则匹配,将%E2%80%8B替换掉

伪代码:

// 在请求发送前
url = decodeURI(encodeURI(url).replace(/%E2%80%8B/g, ''))

遇到这个问题其实挺无奈的,一个并不起眼的地方,如果你不注意,会导致你做很多无用功

查看原文

DAMWIHNLFTM 收藏了文章 · 2020-07-24

你TM管这玩意儿叫H5编辑器?????

作为一名程序员,产品经理、设计师等身兼数职的我,推荐一款年度最佳的H5编辑器给大家,真心牛逼,业界良心,看的我热血沸腾,回家一脚踢飞正在熟睡的哈士奇!

H5DS编辑器,软件截图:

592956246-5f0edf4a2a0a5.gif

推荐理由1:时间轴

支持动画时间轴,调试动画和音频

image.png

给图层添加动画后可以通过时间轴设置动画,真的很直观和方便。效率提升N倍。其中紫色块表示进入动画,蓝色是离开动画,黄色块是强调动画,绿色块表示音频,另外时间轴开可以设置音乐播放的开始时间。

推荐理由2:批量操作

批量选中后,还可以一键布局,批量粘贴动画,批量设置动画间隔,真是强大的不要不要呀~

image.png

推荐理由3:逐帧动画

逐帧动画哇~ 真的优秀!

image.png

推荐理由4:路径动画

还可以自定义路径动画,贝塞尔曲线,钢笔工具????!!!这就叫专业

image.png

推荐理由5:黑暗模式

黑暗模式,传说中只有IOS才有的黑暗模式,居然在H5DS编辑器中也有,喜欢黑色的编辑器么?那就开启黑暗模式吧!

image.png

推荐理由6:下载代码

尼玛,还可以把做好的作品下载代码到本地。一次只要10个积分,积分不够可以关注官方的公众号,直接赠送500个积分,每天签到可以赠送10个积分。真的很良心~ 积分用完还可以找作者要积分,基本上可以免费使用,之所以为什么不直接免费?因为是怕有人恶意下载导致服务器负载过高挂了大家都没得玩了。

image.png

推荐理由7:强大音频

语音合成,在线录音,上传音频,你需要的都有!

image.png

image.png

推荐理由8:浮动层

唯一一款支持多浮动层的编辑器,给浮动层添加元素后每个页面都会全部加上元素,这个操作比较骚,真的~

image.png

图解浮动层:

微信截图_20200723190133.png

推荐理由9:吸附定位

吸附定位,这个概念真的比较新,通过名字可以了解到这个功能可以让元素吸附到窗口指定的位置,对这个研究了一下发现这个功能真的很酷,特别是解决各种机型兼容性的问题。

推荐理由10:自定义脚本

额,还可以自定义JS脚本,编辑器实现不了的功能,脚本帮你实现。老板:你TM管着玩意儿叫H5编辑器?????可以写代码的编辑器我还是第一次见。

image.png

推荐理由11:丰富的自定义交互功能

编辑器提供了各种交互功能,比如切换页面、拖动、超链接、打电话、隐藏显示图层、弹窗控制、音频控制等丰富的交互功能。真香~

image

推荐理由12:插件可扩展

编辑器自带了很多插件,这款叫H5DS的编辑器更牛逼了,插件还可以自己开发,为了让插件开发者更爽,居然自己开发了一套UI库,哦,你没看错,真的是一套UI库。【h5ds-ui】

woc~ 能不能好好的做编辑器!不得不说真的奥利给呀~

image.png

image.png

推荐理由13:H5DS-JSSDK

额,居然自己开发了一套JSSDK,让React开发的H5DS在vue, angular中大放异彩。传说中的混合开发来了????

image.png

推荐理由14:

推荐H5DS的理由很简单,我TM就是作者!【H5DS网址】

非要逼我跪下来求你才会给我点个赞吗?????老子堂堂七尺男儿!

image.png

大爷!赏个赞吧!

查看原文

DAMWIHNLFTM 收藏了文章 · 2020-07-22

实战技巧,Vue原来还可以这样写

两只黄鹂鸣翠柳,一堆bug上西天。

每天上班写着重复的代码,当一个cv仔,忙到八九点,工作效率低,感觉自己没有任何提升。如何能更快的完成手头的工作,今天小编整理了一些新的Vue使用技巧。你们先加班,我先下班陪女神去逛街了。

本文首发于公众号【前端有的玩】,关注我,我们一起玩前端,每天都有不一样的干货知识点等着你哦

hookEvent,原来可以这样监听组件生命周期

1. 内部监听生命周期函数

今天产品经理又给我甩过来一个需求,需要开发一个图表,拿到需求,瞄了一眼,然后我就去echarts官网复制示例代码了,复制完改了改差不多了,改完代码长这样


<template>
  <div class="echarts"></div>
</template>
<script>
  export default {
   mounted() {
     this.chart = echarts.init(this.$el)
      // 请求数据,赋值数据 等等一系列操作...
      // 监听窗口发生变化,resize组件
     window.addEventListener('resize',this.$_handleResizeChart)
  },
  updated() {
    // 干了一堆活
  },
  created() {
     // 干了一堆活
  },
  beforeDestroy() {
    // 组件销毁时,销毁监听事件
    window.removeEventListener('resize', this.$_handleResizeChart)
  },
  methods: {
    $_handleResizeChart() {
     this.chart.resize()
    },
  // 其他一堆方法
 }
}
</script>

功能写完开开心心的提测了,测试没啥问题,产品经理表示做的很棒。然而code review时候,技术大佬说了,这样有问题。

大佬:这样写不是很好,应该将监听resize事件与销毁resize事件放到一起,现在两段代码分开而且相隔几百行代码,可读性比较差
我:那我把两个生命周期钩子函数位置换一下,放到一起?
大佬:hook听过没?
我:Vue3.0才有啊,咋,咱要升级Vue?

然后技术大佬就不理我了,并向我扔过来一段代码

export default {
  mounted() {
    this.chart = echarts.init(this.$el)
    // 请求数据,赋值数据 等等一系列操作...
    // 监听窗口发生变化,resize组件
    window.addEventListener('resize', this.$_handleResizeChart)
    // 通过hook监听组件销毁钩子函数,并取消监听事件
    this.$once('hook:beforeDestroy', () => {
      window.removeEventListener('resize', this.$\_handleResizeChart)
    })
  },
  updated() {},
  created() {},
  methods: {
    $_handleResizeChart() {
      this.chart.resize()
    }
  }
}

看完代码,恍然大悟,大佬不愧是大佬,原来`Vue`还可以这样监听生命周期函数。

_在`Vue`组件中,可以用过`$on\`,\`$once`去监听所有的生命周期钩子函数,如监听组件的`updated`钩子函数可以写成 `this.$on('hook:updated', () => {})`_

2. 外部监听生命周期函数

今天同事在公司群里问,想在外部监听组件的生命周期函数,有没有办法啊?

为什么会有这样的需求呢,原来同事用了一个第三方组件,需要监听第三方组件数据的变化,但是组件又没有提供change事件,同事也没办法了,才想出来要去在外部监听组件的updated钩子函数。查看了一番资料,发现Vue支持在外部监听组件的生命周期钩子函数。

<template>
   <!--通过@hook:updated监听组件的updated生命钩子函数-->
   <!--组件的所有生命周期钩子都可以通过@hook:钩子函数名 来监听触发-->
   <custom-select @hook:updated="$_handleSelectUpdated" />
</template>
<script>
  import CustomSelect from '../components/custom-select'
  export default {
     components: {
        CustomSelect
     },
   methods: {
     $_handleSelectUpdated() {
       console.log('custom-select组件的updated钩子函数被触发')
     }
   }
 }
</script>

小项目还用Vuex?用Vue.observable手写一个状态管理吧

在前端项目中,有许多数据需要在各个组件之间进行传递共享,这时候就需要有一个状态管理工具,一般情况下,我们都会使用Vuex,但对于小型项目来说,就像Vuex官网所说:“如果您不打算开发大型单页应用,使用 Vuex 可能是繁琐冗余的。确实是如此——如果您的应用够简单,您最好不要使用 Vuex”。这时候我们就可以使用Vue2.6提供的新API Vue.observable手动打造一个Vuex

1. 创建 store

import Vue from 'vue'
// 通过Vue.observable创建一个可响应的对象
export const store = Vue.observable({
  userInfo: {},
  roleIds: []
})
// 定义 mutations, 修改属性
export const mutations = {
   setUserInfo(userInfo) {
     store.userInfo = userInfo
   },
   setRoleIds(roleIds) {
     store.roleIds = roleIds
   }
}

2. 在组件中引用

<template>
   <div>
     {{ userInfo.name }}
   </div>
</template>
<script>
  import { store, mutations } from '../store'
  export default {
    computed: {
      userInfo() {
        return store.userInfo 
      }
   },
   created() {
     mutations.setUserInfo({
       name: '子君'
     })
   }
}
</script>

开发全局组件,你可能需要了解一下Vue.extend

Vue.extend是一个全局Api,平时我们在开发业务的时候很少会用到它,但有时候我们希望可以开发一些全局组件比如Loading,Notify,Message等组件时,这时候就可以使用Vue.extend

同学们在使用element-uiloading时,在代码中可能会这样写

// 显示loading
const loading = this.$loading()
// 关闭loading
loading.close()

这样写可能没什么特别的,但是如果你这样写

const loading = this.$loading()
const loading1 = this.$loading()
setTimeout(() => {
  loading.close()
}, 1000 * 3)

这时候你会发现,我调用了两次loading,但是只出现了一个,而且我只关闭了loading,但是loading1也被关闭了。这是怎么实现的呢?我们现在就是用Vue.extend + 单例模式去实现一个loading

1. 开发loading组件

<template>
  <transition name="custom-loading-fade">
    <!--loading蒙版-->
    <div v-show="visible" class="custom-loading-mask">
      <!--loading中间的图标-->
      <div class="custom-loading-spinner">
        <i class="custom-spinner-icon"></i>
        <!--loading上面显示的文字-->
        <p class="custom-loading-text">{{ text }}</p>
      </div>
    </div>
  </transition>
</template>
<script>
export default {
  props: {
  // 是否显示loading
    visible: {
      type: Boolean,
      default: false
    },
    // loading上面的显示文字
    text: {
      type: String,
      default: ''
    }
  }
}
</script>

开发出来loading组件之后,如果需要直接使用,就要这样去用

<template>
  <div class="component-code">
    <!--其他一堆代码-->
    <custom-loading :visible="visible" text="加载中" />
  </div>
</template>
<script>
export default {
  data() {
    return {
      visible: false
    }
  }
}
</script>

但这样使用并不能满足我们的需求

  1. 可以通过js直接调用方法来显示关闭
  2. loading可以将整个页面全部遮罩起来

2.通过Vue.extend将组件转换为全局组件

1. 改造loading组件,将组件的props改为data

export default {
  data() {
    return {
      text: '',
      visible: false
    }
  }
}

2. 通过Vue.extend改造组件

// loading/index.js
import Vue from 'vue'
import LoadingComponent from './loading.vue'

// 通过Vue.extend将组件包装成一个子类
const LoadingConstructor = Vue.extend(LoadingComponent)

let loading = undefined

LoadingConstructor.prototype.close = function() {
  // 如果loading 有引用,则去掉引用
  if (loading) {
    loading = undefined
  }
  // 先将组件隐藏
  this.visible = false
  // 延迟300毫秒,等待loading关闭动画执行完之后销毁组件
  setTimeout(() => {
    // 移除挂载的dom元素
    if (this.$el && this.$el.parentNode) {
      this.$el.parentNode.removeChild(this.$el)
    }
    // 调用组件的$destroy方法进行组件销毁
    this.$destroy()
  }, 300)
}

const Loading = (options = {}) => {
  // 如果组件已渲染,则返回即可
  if (loading) {
    return loading
  }
  // 要挂载的元素
  const parent = document.body
  // 组件属性
  const opts = {
    text: '',
    ...options
  }
  // 通过构造函数初始化组件 相当于 new Vue()
  const instance = new LoadingConstructor({
    el: document.createElement('div'),
    data: opts
  })
  // 将loading元素挂在到parent上面
  parent.appendChild(instance.$el)
  // 显示loading
  Vue.nextTick(() => {
    instance.visible = true
  })
  // 将组件实例赋值给loading
  loading = instance
  return instance
}

export default Loading

3. 在页面使用loading

import Loading from './loading/index.js'
export default {
  created() {
    const loading = Loading({ text: '正在加载。。。' })
    // 三秒钟后关闭
    setTimeout(() => {
      loading.close()
    }, 3000)
  }
}

通过上面的改造,loading已经可以在全局使用了,如果需要像element-ui一样挂载到Vue.prototype上面,通过this.$loading调用,还需要改造一下

4. 将组件挂载到Vue.prototype上面

Vue.prototype.$loading = Loading
// 在export之前将Loading方法进行绑定
export default Loading

// 在组件内使用
this.$loading()

自定义指令,从底层解决问题

什么是指令?指令就是你女朋友指着你说,“那边搓衣板,跪下,这是命令!”。开玩笑啦,程序员哪里会有女朋友。

通过上一节我们开发了一个loading组件,开发完之后,其他开发在使用的时候又提出来了两个需求

  1. 可以将loading挂载到某一个元素上面,现在只能是全屏使用
  2. 可以使用指令在指定的元素上面挂载loading

有需求,咱就做,没话说

1.开发v-loading指令

import Vue from 'vue'
import LoadingComponent from './loading'
// 使用 Vue.extend构造组件子类
const LoadingContructor = Vue.extend(LoadingComponent)

// 定义一个名为loading的指令
Vue.directive('loading', {
  /**
   * 只调用一次,在指令第一次绑定到元素时调用,可以在这里做一些初始化的设置
   * @param {*} el 指令要绑定的元素
   * @param {*} binding 指令传入的信息,包括 {name:'指令名称', value: '指令绑定的值',arg: '指令参数 v-bind:text 对应 text'}
   */
  bind(el, binding) {
    const instance = new LoadingContructor({
      el: document.createElement('div'),
      data: {}
    })
    el.appendChild(instance.$el)
    el.instance = instance
    Vue.nextTick(() => {
      el.instance.visible = binding.value
    })
  },
  /**
   * 所在组件的 VNode 更新时调用
   * @param {*} el
   * @param {*} binding
   */
  update(el, binding) {
    // 通过对比值的变化判断loading是否显示
    if (binding.oldValue !== binding.value) {
      el.instance.visible = binding.value
    }
  },
  /**
   * 只调用一次,在 指令与元素解绑时调用
   * @param {*} el
   */
  unbind(el) {
    const mask = el.instance.$el
    if (mask.parentNode) {
      mask.parentNode.removeChild(mask)
    }
    el.instance.$destroy()
    el.instance = undefined
  }
})

2.在元素上面使用指令

<template>
  <div v-loading="visible"></div>
</template>
<script>
export default {
  data() {
    return {
      visible: false
    }
  },
  created() {
    this.visible = true
    fetch().then(() => {
      this.visible = false
    })
  }
}
</script>

3.项目中哪些场景可以自定义指令

  1. 为组件添加loading效果
  2. 按钮级别权限控制 v-permission
  3. 代码埋点,根据操作类型定义指令
  4. input输入框自动获取焦点
  5. 其他等等。。。

深度watchwatch立即触发回调,我可以监听到你的一举一动

在开发Vue项目时,我们会经常性的使用到watch去监听数据的变化,然后在变化之后做一系列操作。

1.基础用法

比如一个列表页,我们希望用户在搜索框输入搜索关键字的时候,可以自动触发搜索,此时除了监听搜索框的change事件之外,我们也可以通过watch监听搜索关键字的变化

<template>
  <!--此处示例使用了element-ui-->
  <div>
    <div>
      <span>搜索</span>
      <input v-model="searchValue" />
    </div>
    <!--列表,代码省略-->
  </div>
</template>
<script>
export default {
  data() {
    return {
      searchValue: ''
    }
  },
  watch: {
    // 在值发生变化之后,重新加载数据
    searchValue(newValue, oldValue) {
      // 判断搜索
      if (newValue !== oldValue) {
        this.$_loadData()
      }
    }
  },
  methods: {
    $_loadData() {
      // 重新加载数据,此处需要通过函数防抖
    }
  }
}
</script>

2.立即触发

通过上面的代码,现在已经可以在值发生变化的时候触发加载数据了,但是如果要在页面初始化时候加载数据,我们还需要在created或者mounted生命周期钩子里面再次调用$_loadData方法。不过,现在可以不用这样写了,通过配置watch的立即触发属性,就可以满足需求了

// 改造watch
export default {
  watch: {
    // 在值发生变化之后,重新加载数据
    searchValue: {
    // 通过handler来监听属性变化, 初次调用 newValue为""空字符串, oldValue为 undefined
      handler(newValue, oldValue) {
        if (newValue !== oldValue) {
          this.$_loadData()
        }
      },
      // 配置立即执行属性
      immediate: true
    }
  }
}

3.深度监听(我可以看到你内心的一举一动)

一个表单页面,需求希望用户在修改表单的任意一项之后,表单页面就需要变更为被修改状态。如果按照上例中watch的写法,那么我们就需要去监听表单每一个属性,太麻烦了,这时候就需要用到watch的深度监听deep

export default {
  data() {
    return {
      formData: {
        name: '',
        sex: '',
        age: 0,
        deptId: ''
      }
    }
  },
  watch: {
    // 在值发生变化之后,重新加载数据
    formData: {
      // 需要注意,因为对象引用的原因, newValue和oldValue的值一直相等
      handler(newValue, oldValue) {
        // 在这里标记页面编辑状态
      },
      // 通过指定deep属性为true, watch会监听对象里面每一个值的变化
      deep: true
    }
  }
}

随时监听,随时取消,了解一下$watch

有这样一个需求,有一个表单,在编辑的时候需要监听表单的变化,如果发生变化则保存按钮启用,否则保存按钮禁用。这时候对于新增表单来说,可以直接通过watch去监听表单数据(假设是formData),如上例所述,但对于编辑表单来说,表单需要回填数据,这时候会修改formData的值,会触发watch,无法准确的判断是否启用保存按钮。现在你就需要了解一下$watch

export default {
  data() {
    return {
      formData: {
        name: '',
        age: 0
      }
    }
  },
  created() {
    this.$_loadData()
  },
  methods: {
    // 模拟异步请求数据
    $_loadData() {
      setTimeout(() => {
        // 先赋值
        this.formData = {
          name: '子君',
          age: 18
        }
        // 等表单数据回填之后,监听数据是否发生变化
        const unwatch = this.$watch(
          'formData',
          () => {
            console.log('数据发生了变化')
          },
          {
            deep: true
          }
        )
        // 模拟数据发生了变化
        setTimeout(() => {
          this.formData.name = '张三'
        }, 1000)
      }, 1000)
    }
  }
}

根据上例可以看到,我们可以在需要的时候通过this.$watch来监听数据变化。那么如何取消监听呢,上例中this.$watch返回了一个值unwatch,是一个函数,在需要取消的时候,执行 unwatch()即可取消

函数式组件,函数是组件?

什么是函数式组件?函数式组件就是函数是组件,感觉在玩文字游戏。使用过React的同学,应该不会对函数式组件感到陌生。函数式组件,我们可以理解为没有内部状态,没有生命周期钩子函数,没有this(不需要实例化的组件)。

在日常写bug的过程中,经常会开发一些纯展示性的业务组件,比如一些详情页面,列表界面等,它们有一个共同的特点是只需要将外部传入的数据进行展现,不需要有内部状态,不需要在生命周期钩子函数里面做处理,这时候你就可以考虑使用函数式组件。

1. 先来一个函数式组件的代码

export default {
  // 通过配置functional属性指定组件为函数式组件
  functional: true,
  // 组件接收的外部属性
  props: {
    avatar: {
      type: String
    }
  },
  /**
   * 渲染函数
   * @param {*} h
   * @param {*} context 函数式组件没有this, props, slots等都在context上面挂着
   */
  render(h, context) {
    const { props } = context
    if (props.avatar) {
      return <img data-original={props.avatar}></img>
    }
    return <img data-original="default-avatar.png"></img>
  }
}

在上例中,我们定义了一个头像组件,如果外部传入头像,则显示传入的头像,否则显示默认头像。上面的代码中大家看到有一个render函数,这个是Vue使用JSX的写法,关于JSX,小编将在后续文章中会出详细的使用教程。

2.为什么使用函数式组件

  1. 最主要最关键的原因是函数式组件不需要实例化,无状态,没有生命周期,所以渲染性能要好于普通组件
  2. 函数式组件结构比较简单,代码结构更清晰

3. 函数式组件与普通组件的区别

  1. 函数式组件需要在声明组件是指定functional
  2. 函数式组件不需要实例化,所以没有this,this通过render函数的第二个参数来代替
  3. 函数式组件没有生命周期钩子函数,不能使用计算属性,watch等等
  4. 函数式组件不能通过$emit对外暴露事件,调用事件只能通过context.listeners.click的方式调用外部传入的事件
  5. 因为函数式组件是没有实例化的,所以在外部通过ref去引用组件时,实际引用的是HTMLElement
  6. 函数式组件的props可以不用显示声明,所以没有在props里面声明的属性都会被自动隐式解析为prop,而普通组件所有未声明的属性都被解析到$attrs里面,并自动挂载到组件根元素上面(可以通过inheritAttrs属性禁止)

4.我不想用JSX,能用函数式组件吗?

Vue2.5之前,使用函数式组件只能通过JSX的方式,在之后,可以通过模板语法来生命函数式组件

<!--在template 上面添加 functional属性-->
<template functional>
  <img :data-original="props.avatar ? props.avatar : 'default-avatar.png'" />
</template>
<!--根据上一节第六条,可以省略声明props-->

结语:

不要吹灭你的灵感和你的想象力; 不要成为你的模型的奴隶。 ——文森特・梵高

扫码关注,愿你遇到那个心疼你付出的人~

微信公众号宣传图.gif

查看原文

DAMWIHNLFTM 收藏了文章 · 2020-07-22

elementUI 不用在写rule来作表单校验啦

相信很多人都有过这样的代码

{
  name: [
    { required: true, message: '请输入活动名称', trigger: 'blur' },
  ],
  region: [
    { required: true, message: '请选择活动区域', trigger: 'change' }
  ],
}

额滴恶瓜脑膜炎上帝啊, 这是要坐实我搬砖皇帝的身份么,一个required要我写这么多代码? 我就一个必填, 你默认给我整个英文,还是

name is required

唉, 不得不想出点脑瓜仁方便方便了。
二话不说,
我包装了下el-form, 看下:

<template>
  <el-form ref="form" v-bind="$attrs" :rules="defaultRules" size="small">
    <slot></slot>
  </el-form>
</template>

<script>
import validator from "./validator";
/**
 * Basic Form Components
 */
export default {
  inheritAttrs: false,
  data() {
    return {
      defaultRules: {}, // Default configuration made by interception
      validateList: [],
    };
  },
  created() {
    // 读取规则列表
    this.readRuleList();

    const arr = this.$slots.default.map(v => ({
      ...v.componentOptions.propsData,
      ...v.data.attrs
    }));
    arr.forEach(v => {
      if (Object.prototype.hasOwnProperty.call(v, "required")) {
        if (!this.defaultRules[v.prop]) {
          this.$set(this.defaultRules, v.prop, []);
        }
        this.defaultRules[v.prop].push({
          required: true,
          message: `${v.label}不能为空`, // 重点这句
          // trigger: "blur" 
        });
      }

      this.validateList.forEach(val => {
        if (Object.prototype.hasOwnProperty.call(v, val)) {
          if (!this.defaultRules[v.prop]) {
            this.$set(this.defaultRules, v.prop, []);
          }
          this.defaultRules[v.prop].push({
            validator: validator[val](this),
            trigger: "blur"
          });
        }
      });
    });
  },
  methods: {
    validate(fn) {
      return this.$refs.form.validate(fn);
    },
    reset() {
      this.$refs.form.resetFields();
    },
    readRuleList() {
      this.validateList = Object.keys(require("./validator/index").default);
    }
  }
};
</script>

这么用:

 <base-form>
    <el-form-item prop="title" label="标题" required>
              <el-input v-model="addForm.title"></el-input>
            </el-form-item>
 </base-form>
就只要在form-item上写个 required! message默认就是标题不能为空

当然这个base-form还可以随便加定好的属性,你看:

validator.js
/**
 * Verifier
 */
export default {
  mobile: () => (rule, value, callback) => {
    if (!/^1[0-9]{10}$/.test(value)) {
      callback(new Error("手机号码错误"));
    } else {
      callback();
    }
  },

  email: () => (rule, value, callback) => {
    if (!/[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test(value)) {
      callback(new Error("电子邮箱格式错误"));
    } else {
      callback();
    }
  }
};

你只要这么使用:

<base-form>
    <el-form-item prop="title" label="标题" email>
              <el-input v-model="addForm.title"></el-input>
            </el-form-item>
 </base-form>

邮箱验证就加上去啦!

当当当然,阔以叠加

<base-form>
    <el-form-item prop="title" label="标题" email required>
              <el-input v-model="addForm.title"></el-input>
            </el-form-item>
 </base-form>

ohohohohohohohoh!!

当当当当当当然, base-form是不会冲掉原本el-form的属性的, 这么炫炫的组件不copy试试吗?

查看原文

DAMWIHNLFTM 收藏了文章 · 2020-07-14

轻松理解JS中的面向对象,顺便搞懂prototype和__proto__

这篇文章主要讲一下JS中面向对象以及 __proto__ptototypeconstructor,这几个概念都是相关的,所以一起讲了。

在讲这个之前我们先来说说类,了解面向对象的朋友应该都知道,如果我要定义一个通用的类型我可以使用类(class)。比如在java中我们可以这样定义一个类:

public class Puppy{
    int puppyAge;

    public Puppy(age){
      puppyAge = age;
    }
  
    public void say() {
      System.out.println("汪汪汪"); 
    }
}

上述代码我们定义了一个Puppy类,这个类有一个属性是puppyAge,也就是小狗的年龄,然后有一个构造函数Puppy(),这个构造函数接收一个参数,可以设置小狗的年龄,另外还有一个说话的函数say。这是一个通用的类,当我们需要一个两岁的小狗实例是直接这样写,这个实例同时具有父类的方法:

Puppy myPuppy = new Puppy( 2 );
myPuppy.say();     // 汪汪汪

但是早期的JS没有class关键字啊(以下说JS没有class关键字都是指ES6之前的JS,主要帮助大家理解概念),JS为了支持面向对象,使用了一种比较曲折的方式,这也是导致大家迷惑的地方,其实我们将这种方式跟一般的面向对象类比起来就很清晰了。下面我们来看看JS为了支持面向对象需要解决哪些问题,都用了什么曲折的方式来解决。

没有class,用函数代替

首先JS连class关键字都没有,怎么办呢?用函数代替,JS中最不缺的就是函数,函数不仅能够执行普通功能,还能当class使用。比如我们要用JS建一个小狗的类怎么写呢?直接写一个函数就行:

function Puppy() {}

这个函数可以直接用new关键字生成实例:

const myPuppy = new Puppy();

这样我们也有了一个小狗实例,但是我们没有构造函数,不能设置小狗年龄啊。

函数本身就是构造函数

当做类用的函数本身也是一个函数,而且他就是默认的构造函数。我们想让Puppy函数能够设置实例的年龄,只要让他接收参数就行了。

function Puppy(age) {
  this.puppyAge = age;
}

// 实例化时可以传年龄参数了
const myPuppy = new Puppy(2);

注意上面代码的this,被作为类使用的函数里面this总是指向实例化对象,也就是myPuppy。这么设计的目的就是让使用者可以通过构造函数给实例对象设置属性,这时候console出来看myPuppy.puppyAge就是2。

console.log(myPuppy.puppyAge);   // 输出是 2

实例方法用prototype

上面我们实现了类和构造函数,但是类方法呢?Java版小狗还可以“汪汪汪”叫呢,JS版怎么办呢?JS给出的解决方案是给方法添加一个prototype属性,挂载在这上面的方法,在实例化的时候会给到实例对象。我们想要myPuppy能说话,就需要往Puppy.prototype添加说话的方法。

Puppy.prototype.say = function() {
  console.log("汪汪汪");
}

使用new关键字产生的实例都有类的prototype上的属性和方法,我们在Puppy.prototype上添加了say方法,myPuppy就可以说话了,我么来试一下:

myPuppy.say();    // 汪汪汪

实例方法查找用__proto__

那myPuppy怎么就能够调用say方法了呢,我们把他打印出来看下,这个对象上并没有say啊,这是从哪里来的呢?

image-20200221180325943

这就该__proto__上场了,当你访问一个对象上没有的属性时,比如myPuppy.say,对象会去__proto__查找。__proto__的值就等于父类的prototype, myPuppy.__proto__指向了Puppy.prototype

image-20200221181132495

如果你访问的属性在Puppy.prototype也不存在,那又会继续往Puppy.prototype.__proto__上找,这时候其实就找到了Object.prototype了,Object.prototype再往上找就没有了,也就是null,这其实就是原型链

image-20200221181533277

constructor

我们说的constructor一般指类的prototype.constructorprototype.constructor是prototype上的一个保留属性,这个属性就指向类函数本身,用于指示当前类的构造函数。

image-20200221183238691

image-20200221182045545

既然prototype.constructor是指向构造函数的一个指针,那我们是不是可以通过它来修改构造函数呢?我们来试试就知道了。我们先修改下这个函数,然后新建一个实例看看效果:

function Puppy(age) {
  this.puppyAge = age;
}

Puppy.prototype.constructor = function myConstructor(age) {
  this.puppyAge = age + 1;
}

const myPuppy2 = new Puppy(2);
console.log(myPuppy2.puppyAge);    // 输出是2

上例说明,我们修改prototype.constructor只是修改了这个指针而已,并没有修改真正的构造函数。

可能有的朋友会说我打印myPuppy2.constructor也有值啊,那constructor是不是也是对象本身的一个属性呢?其实不是的,之所以你能打印出这个值,是因为你打印的时候,发现myPuppy2本身并不具有这个属性,又去原型链上找了,找到了prototype.constructor。我们可以用hasOwnProperty看一下就知道了:

image-20200222152216426

上面我们其实已经说清楚了prototype__proto__constructor几者之间的关系,下面画一张图来更直观的看下:

image-20200222153906550

静态方法

我们知道很多面向对象有静态方法这个概念,比如Java直接是加一个static关键字就能将一个方法定义为静态方法。JS中定义一个静态方法更简单,直接将它作为类函数的属性就行:

Puppy.statciFunc = function() {    // statciFunc就是一个静态方法
  console.log('我是静态方法,this拿不到实例对象');
}      

Puppy.statciFunc();            // 直接通过类名调用

静态方法和实例方法最主要的区别就是实例方法可以访问到实例,可以对实例进行操作,而静态方法一般用于跟实例无关的操作。这两种方法在jQuery中有大量应用,在jQuery中$(selector)其实拿到的就是实例对象,通过$(selector)进行操作的方法就是实例方法。比如$(selector).append(),这会往这个实例DOM添加新元素,他需要这个DOM实例才知道怎么操作,将append作为一个实例方法,他里面的this就会指向这个实例,就可以通过this操作DOM实例。那什么方法适合作为静态方法呢?比如$.ajax,这里的ajax跟DOM实例没关系,不需要这个this,可以直接挂载在$上作为静态方法。

继承

面向对象怎么能没有继承呢,根据前面所讲的知识,我们其实已经能够自己写一个继承了。所谓继承不就是子类能够继承父类的属性和方法吗?换句话说就是子类能够找到父类的prototype,最简单的方法就是子类原型的__proto__指向父类原型就行了。

function Parent() {}
function Child() {}

Child.prototype.__proto__ = Parent.prototype;

const obj = new Child();
console.log(obj instanceof Child );   // true
console.log(obj instanceof Parent );   // true

上述继承方法只是让Child访问到了Parent原型链,但是没有执行Parent的构造函数:

function Parent() {
  this.parentAge = 50;
}
function Child() {}

Child.prototype.__proto__ = Parent.prototype;

const obj = new Child();
console.log(obj.parentAge);    // undefined

为了解决这个问题,我们不能单纯的修改Child.prototype.__proto__指向,还需要用new执行下Parent的构造函数:

function Parent() {
  this.parentAge = 50;
}
function Child() {}

Child.prototype.__proto__ = new Parent();

const obj = new Child();
console.log(obj.parentAge);    // 50

上述方法会多一个__proto__层级,可以换成修改Child.prototype的指向来解决,注意将Child.prototype.constructor重置回来:

function Parent() {
  this.parentAge = 50;
}
function Child() {}

Child.prototype = new Parent();
Child.prototype.constructor = Child;      // 注意重置constructor

const obj = new Child();
console.log(obj.parentAge);    // 50

组合继承

上面的继承已经有了基本原理,但是并不能适合所有场景,比如构造函数接收参数的场景就不行了,所以我们改造下这个代码:

function Parent(age) {
  this.age = age;    // age从参数传进来
}
function Child(age) {
  Parent.call(this, age);    // 调用父级构造函数,设置age
  // 下面Child可以干自己想干的
}

Child.prototype = new Parent();
Child.prototype.constructor = Child;      

const obj = new Child(50);
console.log(obj.age);    // 50

这段代码里面,我们在Child里面先调用了Parent的构造函数,让Parent的初始化也对Child生效。这个函数里面Parent的作用是设置age,因为在Child也运行了,所以Child实例上也有了age。现在我们看下obj长什么样子:

image-20201213134136809

嗯,obj.age50,符合我们的预期,但是obj.__proto__.age是从哪里来的,而且还有个值是undefined

寄生组合继承

上面的obj.__proto__.age其实是我们继承时运行下面这行代码来的:

Child.prototype = new Parent();

这行代码的作用是给Child.prototype设置值,但是设置的方式是执行了一次Parent的构造函数,执行Parent的构造函数构造函数自然就会运行this.age = age; ,由于我们没给age传值,所以他就是undefined的。但是这种结果不是我们想要的!我们已经在Child构造函数里面调用过Parent构造函数了,已经设置了ageChild的实例上,我们不需要再来个age到实例的原型上!而且这会造成一种后果,即使我们delete obj.age,我们仍然能够在原型上访问到这个值,虽然这会儿他的值是undefined,但是仍然跟我们预期不一样。换句话说,Child继承Parent的时候,我只希望Child.prototype获取Parent.prototype上的值,而不需要Parent实例属性。要解决这个问题,我们就引入了寄生组合继承,这种继承方式跟上面的只有一行代码不一样:

function Parent(age) {
  this.age = age;    
}
Parent.prototype.instanceFunc = () => {}    // 为了看清楚,我们给Parent加了一个实例方法,不影响继承方式

function Child(age) {
  Parent.call(this, age);    
}

Child.prototype = Object.create(Parent.prototype);                 // 就这一行代码不一样
Child.prototype.constructor = Child;      

const obj = new Child(50);
console.log(obj.age);    // 50

上面代码就给Child.prototype赋值这一行不一样,从:

Child.prototype = new Parent();

变成了:

Child.prototype = Object.create(Parent.prototype);

运行结果是一样的,但是我们把obj打印出来看看就不一样了:

image-20201213135401527

我们发现obj.__proto__.age这个属性不存在了,但是Parent的实例方法instanceFunc我们还是能够访问的。这就符合我们的预期了,实现这个的关键是使用Object.create(proto),这个方法会创建一个新的对象newObj,并让这个newObj的原型指向传入的参数proto,即:

newObj.__proto__ = proto;

然后将newObj赋值给Child.prototype就实现了上述效果,这就是寄生组合继承,也是后面要说的Class关键字的实现方式。

自己实现一个new

结合上面讲的,我们知道new其实就是生成了一个对象,这个对象能够访问类的原型,知道了原理,我们就可以自己实现一个new了。

function myNew(func, ...args) {
  const obj = {};     // 新建一个空对象
  const result = func.call(obj, ...args);  // 执行构造函数
  obj.__proto__ = func.prototype;    // 设置原型链
  
  // 注意如果原构造函数有Object类型的返回值,包括Functoin, Array, Date, RegExg, Error
  // 那么应该返回这个返回值
  const isObject = typeof result === 'object' && result !== null;
  const isFunction = typeof result === 'function';
  if(isObject || isFunction) {
    return result;
  }
  
  // 原构造函数没有Object类型的返回值,返回我们的新对象
  return obj;
}

function Puppy(age) {
  this.puppyAge = age;
}

Puppy.prototype.say = function() {
  console.log("汪汪汪");
}

const myPuppy3 = myNew(Puppy, 2);

console.log(myPuppy3.puppyAge);  // 2
console.log(myPuppy3.say());     // 汪汪汪

自己实现一个instanceof

知道了原理,其实我们也知道了instanceof是干啥的。instanceof不就是检查一个对象是不是某个类的实例吗?换句话说就是检查一个对象的的原型链上有没有这个类的prototype,知道了这个我们就可以自己实现一个了:

function myInstanceof(targetObj, targetClass) {
  // 参数检查
  if(!targetObj || !targetClass || !targetObj.__proto__ || !targetClass.prototype){
    return false;
  }
  
  let current = targetObj;
  
  while(current) {   // 一直往原型链上面找
    if(current.__proto__ === targetClass.prototype) {
      return true;    // 找到了返回true
    }
    
    current = current.__proto__;
  }
  
  return false;     // 没找到返回false
}

// 用我们前面的继承实验下
function Parent() {}
function Child() {}

Child.prototype.__proto__ = Parent.prototype;

const obj = new Child();
console.log(myInstanceof(obj, Child) );   // true
console.log(myInstanceof(obj, Parent) );   // true
console.log(myInstanceof({}, Parent) );   // false

ES6的class

最后还是提一嘴ES6的class,其实ES6的class就是前面说的函数类的语法糖,比如我们的Puppy用ES6的class写就是这样:

class Puppy {
  // 构造函数
  constructor(age) {            
    this.puppyAge = age;
  }
  
  // 实例方法
  say() {
    console.log("汪汪汪")
  }
  
  // 静态方法
  static statciFunc() {
    console.log('我是静态方法,this拿不到实例对象');
  }
}

const myPuppy = new Puppy(2);
console.log(myPuppy.puppyAge);    // 2
console.log(myPuppy.say());       // 汪汪汪
console.log(Puppy.statciFunc());  // 我是静态方法,this拿不到实例对象

使用class可以让我们的代码看起来更像标准的面向对象,构造函数,实例方法,静态方法都有明确的标识。但是他本质只是改变了一种写法,所以可以看做是一种语法糖,如果你去看babel编译后的代码,你会发现他其实也是把class编译成了我们前面的函数类,extends关键字也是使用我们前面的原型继承的方式实现的。

总结

最后来个总结,其实前面小节的标题就是核心了,我们再来总结下:

  1. JS中的函数可以作为函数使用,也可以作为类使用
  2. 作为类使用的函数实例化时需要使用new
  3. 为了让函数具有类的功能,函数都具有prototype属性。
  4. 为了让实例化出来的对象能够访问到prototype上的属性和方法,实例对象的__proto__指向了类的prototype。所以prototype是函数的属性,不是对象的。对象拥有的是__proto__,是用来查找prototype的。
  5. prototype.constructor指向的是构造函数,也就是类函数本身。改变这个指针并不能改变构造函数。
  6. 对象本身并没有constructor属性,你访问到的是原型链上的prototype.constructor
  7. 函数本身也是对象,也具有__proto__,他指向的是JS内置对象Function的原型Function.prototype。所以你才能调用func.call,func.apply这些方法,你调用的其实是Function.prototype.callFunction.prototype.apply
  8. prototype本身也是对象,所以他也有__proto__,指向了他父级的prototype__proto__prototype的这种链式指向构成了JS的原型链。原型链的最终指向是Object的原型。Object上面原型链是null,即Object.prototype.__proto__ === null
  9. 另外要注意的是Function.__proto__ === Function.prototype,这是因为JS中所有函数的原型都是Function.prototype,也就是说所有函数都是Function的实例。Function本身也是可以作为函数使用的----Function(),所以他也是Function的一个实例。类似的还有ObjectArray等,他们也可以作为函数使用:Object(), Array()。所以他们本身的原型也是Function.prototype,即Object.__proto__ === Function.prototype。换句话说,这些可以new的内置对象其实都是一个类,就像我们的Puppy类一样。
  10. ES6的class其实是函数类的一种语法糖,书写起来更清晰,但原理是一样的。

再来看一下完整图:

image-20200222160832782

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

认证与成就

  • 获得 26 次点赞
  • 获得 31 枚徽章 获得 1 枚金徽章, 获得 9 枚银徽章, 获得 21 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-04-14
个人主页被 775 人浏览