李大雷

李大雷 查看完整档案

广州编辑广东工业大学  |  软件工程 编辑蓝月亮  |  前端工程师 编辑 www.leelei.info 编辑
编辑

想学唱歌的码农

个人动态

李大雷 发布了文章 · 9月9日

js使用transition效果实现无缝滚动

image

前言

无缝轮播一直是面试的热门题目,而大部分答案都是复制第一张到最后。诚然,这种方法是非常标准,那么有没有另类一点的方法呢?

第一种方法是需要把所有图片一张张摆好,然后慢慢移动的,

但是我能不能直接不摆就硬移动呢?

如果你使用过vue的transition,我们是可以通过给每一张图片来添加入场动画和离场动画来模拟这个移动

  • 进场动画就是从最右侧到屏幕中央
  • 出场动画是从屏幕中央到左侧移出

这样看起来的效果就是图片从右边一直往左移动,但是这个不一样的地方是,我们每一个元素都有这个进场动画和离场动画,我们根本不用关心它是第几个元素,你只管轮播就是。

如果不用vue呢?

很简单,我们自己实现一个transtition的效果就好啦,主要做的是以下两点

  • 元素显示的时候,即display属性不为none的时候,添加xx-enter-active动画
  • 元素消失的时候,先添加动画xx-leave-active, 注意要让动画播完才消失
 function hide(el){
     el.className = el.className.replace(' slide-enter-active','')
     el.className += ' slide-leave-active'
     el.addEventListener('animationend',animationEvent)
 }
 function animationEvent(e){
     e.target.className = e.target.className.replace(' slide-leave-active','')
     e.target.style.display = 'none'
    e.target.removeEventListener('animationend',animationEvent)
 }
 function show(el){
     el.style.display = 'flex'
     el.className += ' slide-enter-active'
 }

这里我们使用了animationend来监听动画结束,注意这里每次从新添加类的时候需要重新添加监听器,不然会无法监听。如果不使用这个方法你可以使用定时器的方式来移除leave-active类。

 function hide(el){
     el.className = el.className.replace(' slide-enter-active','')
     el.className += ' slide-leave-active'
     setTimeout(()=>{
         //动画结束后清除class
         el.className = el.className.replace(' slide-leave-active','')
         el.style.display = 'none'
     }, ANIMATION_TIME) //这个ANIMATION_TIME为你在css中动画执行的时间
 }

那么,动画怎么写呢?

 .slide-enter-active{
     position: absolute;
     animation: slideIn ease .5s forwards;
 }
 .slide-leave-active{
     position: absolute;
     animation: slideOut ease .5s forwards;
 }
  
 @keyframes slideIn {
     0%{
        transform: translateX(100%);
     }
     100%{
        transform: translateX(0);
     }
 }
 @keyframes slideOut {
     0%{
        transform: translateX(0);
     }
     100%{
        transform: translateX(-100%);
     }
 }

需要注意的是这里的 forwards属性,这个属性表示你的元素状态将保持动画后的状态,如果不设置的话,动画跑完一遍,你的元素本来执行了离开动画,执行完以后会回来中央位置杵着。这个时候你会问了,上面的代码不是写了,动画执行完就隐藏元素吗?

如果你使用上面的setTimeout来命令元素执行完动画后消失,那么可能会有一瞬间的闪烁,因为实际业务中,你的代码可能比较复杂,setTimeout没法在那么精准的时间内执行。保险起见,就让元素保持动画离开的最后状态,即translateX(-100%)。此时元素已经在屏幕外了,不用关心它的表现了

轮播逻辑怎么写?

很简单,我们进一个新元素的时候同时移除旧元素即可,两者同时执行进场和离场动画即可。

 function autoPlay(){
     setTimeout(()=>{
         toggleShow(新元素, 旧元素)
         this.autoPlay()
     },DURATION) //DURATION为动画间隔时间
 }
 function toggleShow(newE,oldE){
     //旧ele和新ele同时动画
     hide(oldE)
     show(newE)
 }

 完整代码

简单的无缝轮播

查看原文

赞 19 收藏 11 评论 7

李大雷 发布了文章 · 7月12日

你可能不知道的V8数组优化

前言

随着Web相关技术的发展,JavaScript所要承担的工作也越来越多,这就更需要快速的解析和执行JavaScript脚本。
在我们实际的运用中,数组绝对算得上是编码过程中一个重要的角色,那么V8对于我们常用的数组又做了哪些优化来使其跑得更快呢?

1.先讲讲number

相信很多人都会被问到这样一个面试的问题

0.2+0.1为什么不等于0.3?

而你也可以很轻易地回答出这是因为js采用的是IEEE754双精度浮点表示法来表示数字,当数字进行计算的时候,需要先进行二进制转换然后进行对阶运算。在进行二进制转换的时候,0.1和0.2因为转换成的是一个无限循环的数,超出了双精度表示法可以表示的长度,因此裁掉了部分的尾数,从而导致0.1和0.2变成了一个近似0.1或者0.2的值,那么它们相加的和就不会等于0.3了

那么,这里就有一个问题了

js中是64位来表示数字,那么在V8引擎层面是否也是使用64位来表示数字呢?

为什么会这么问?
因为我们知道,数字在内存中的表示可以有多种(如下),而64位,显然是最慢的

representationbits
8位二进制补码0010 1001
32位二进制补码0000 0000 0000 0000 0000 0000 0010 1010
二进制编码的十进数码0100 0010
32位 IEEE-754 单精度浮点0100 0010 0010 1000 0000 0000 0000 0000
64位 IEEE-754 双精度浮点0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

我们在使用js这门语言来编程的时候,其实使用的大部分是32位就可以表示的数,因此,引擎做了这样一个优化

ECMAScript 标准约定number数字需要被当成 64 位双精度浮点数处理,但事实上,一直使用 64 位去存储任何数字实际是非常低效的,所以 JavaScript 引擎并不总会使用 64 位去存储数字,引擎在内部采用其他内存表示方式(如 32 位),只要保证数字外部所有能被监测到的特性对齐 64 位的表现就行。

2.对数字的分类Smi和HeapNumber

不仅仅是使用32位来表示数字那么简单,V8还对数字进行了分类,将数字分为了SmiHeapNumber

注意:这个仅仅是引擎层面的处理,js内部只认识数字,不区分整数和浮点数
//32位平台是 2的30次方
//64位平台是 2的31次方
 -Infinity // HeapNumber
-(2**30)-1 // HeapNumber
  -(2**30) // Smi
       -42 // Smi
        -0 // HeapNumber
         0 // Smi
       4.2 // HeapNumber
        42 // Smi
   2**30-1 // Smi
     2**30 // HeapNumber
  Infinity // HeapNumber
       NaN // HeapNumber

可以从上面看出,Smi代表的是小整数,而HeapNumber则代表了一些浮点数以及无法用32位表示的数,比如NaN,Infinity,-0

为什么要区分这两种?
原因还是之前说的,因为小整数在我们的编码过程中太常见了,所以,V8专门把它领出来,并且对其进行了优化操作,它可以进行快速整型操作

那么大概是怎么优化处理呢?
如图所示我们声明了一个对象,对象的x值是一个Smi,而y值是一个HeapNumber,v8给HeapNumber专门分配了一个内存对象来存储值,并将o.y的对象指针指向该内存实体。
image.png

当我们更新他们的值的时候,Smi的值会原地更新,而HeapNumber由于它不可变的特性,V8会开辟一个新的内存实体用来储存新的值,然后将o.y的对象指针指向该内存实体。
image.png

如果我们需要频繁更新HeapNumber的值,执行效率会比Smi慢得多:
在这个短暂的循环中,引擎不得不创建 6 个HeapNumber实例,0.11.12.13.14.15.1,而等到循环结束,其中 5 个实例都会成为垃圾。
image.png

为了防止这个问题,V8 提供了一种优化方式去原地更新非Smi的值:当一个数字内存区域拥有一个非Smi范围内的数值时,V8 会将这块区域标志为Double区域,并会为其分配一个用 64 位浮点表示的MutableHeapNumber实例。
image.png

此后当你再次更新这块区域,V8 就不再需要创建一个新的HeapNumber实例,而可以直接在MutableNumber实例中进行更新了。
image.png

前面说到,HeapNumberMutableNumber都是使用指针引用的方式指向内存实体,而MutableNumber是可变的,如果此时你将属于MutableNumber的值o.x赋值给其他变量y,你一定不希望你下次改变o.x时,y也跟着改变。 为了防止这种情况,当o.x被共享时,o.x内的MutableHeapNumber需要被重新封装成HeapNumber传递给y
image.png

3.数组的常见元素种类

从上面我们学到了一个简单的知识,那就是数字会被v8分为两种,一种是Smi,另一种是HeapNumber,那么这两种表现方式会给其他东西带了什么改变呢?

在 V8 中,如果属性名是数字(最常见的形式是 Array 构造函数生成的对象)会被特殊处理。尽管在许多情况下,这些数字索引属性的行为与其他属性一样,V8 选择将它们与非数字属性分开存储以进行优化。在引擎内部,V8 甚至给这些属性一个特殊的名称:元素

举个例子🌰

const array = [1, 2, 3];

它包含什么样的元素?如果你使用 typeof 操作符,它会告诉你数组包含 number。在语言层面,这就是你所得到的:JavaScript 不区分整数,浮点数和双精度 - 它们只是数字。然而,在引擎级别,我们可以做出更精确的区分。这个数组的元素是 PACKED_SMI_ELEMENTS,而这个SMI就是我们刚刚所说的小整数。

我们可以这个数组中添加一个浮点数将其转换为更通用的元素类型,这里并不叫HeapNumber而是Double,但是我们知道v8确实是把小整数和浮点数分开进行优化处理的。

const array = [1, 2, 3];
// 元素类型: PACKED_SMI_ELEMENTS
array.push(4.56);
// 元素类型: PACKED_DOUBLE_ELEMENTS

向数组添加字符串再次改变其元素类型

const array = [1, 2, 3];
// 元素类型: PACKED_SMI_ELEMENTS
array.push(4.56);
// 元素类型: PACKED_DOUBLE_ELEMENTS
array.push('x');
// 元素类型: PACKED_ELEMENTS

这里重要的一点是,元素种类转换只能从一个方向进行:从特定的(例如 PACKED_SMI_ELEMENTS)到更一般的(例如 PACKED_ELEMENTS)。例如,一旦数组被标记为 PACKED_ELEMENTS,它就不能回到 PACKED_DOUBLE_ELEMENTS

4.密集数组 PACKED 和稀疏数组 HOLEY

const array = [1, 2, 3, 4.56, 'x'];
// 元素类型: PACKED_ELEMENTS
array.length; // 5
array[9] = 1; // array[5] until array[8] are now holes
// 元素类型: HOLEY_ELEMENTS

V8 之所以做这个区别是因为 PACKED 数组的操作比在 HOLEY 数组上的操作更利于进行优化。对于 PACKED 数组,大多数操作可以有效执行。相比之下, HOLEY 数组的操作需要对原型链进行额外的检查和昂贵的查找。

5.元素种类的过渡方向

我们上面就开始一直说 元素类型只能从方向过渡,那么这个方向有一个名字,叫做格 lattice(数学概念)。这是一个简化的可视化,仅显示最常见的元素种类
image.png

只能通过格子向下过渡。一旦将单精度浮点数添加到 Smi 数组中,即使稍后用 Smi 覆盖浮点数,它也会被标记为 DOUBLE。类似地,一旦在数组中创建了一个洞,它将被永久标记为有洞 HOLEY,即使稍后填充它也是如此。

V8 目前有 21 种不同的元素种类,每种元素都有自己的一组可能的优化。

一般来说,更具体的元素种类可以进行更细粒度的优化。元素类型的在格子中越是向下,该对象的操作越慢。为了获得最佳性能,请避免不必要的不具体类型 - 坚持使用符合您情况的最具体的类型。

6.性能优化

从上一部分的我们介绍的结论当中,我们就可以大致摸清楚我们优化的方向,那就是
尽量使你的元素种类单一并且处于比较上层的格,避免元素类型转换

那么落在实处的话,我们有这几种比较具体的优化策略

6.1避免创建洞

const array = new Array(3);
// 此时,数组是稀疏的,所以它被标记为 `HOLEY_SMI_ELEMENTS`
array[0] = 'a';
// 接着,这是一个字符串,而不是一个小整数...所以过渡到`HOLEY_ELEMENTS`。
array[1] = 'b';
array[2] = 'c';
// 这时,数组中的所有三个位置都被填充,所以数组被打包(即不再稀疏)。
// 但是,我们无法转换为更具体的类型,例如 “PACKED_ELEMENTS”。
// 元素类保留为“HOLEY_ELEMENTS”。

那么我们可以怎么做来避免创建洞呢?

let array = []
array.push(newElement) //循环

或者字面量方式

let array = [1,2,3,4,5]

6.2避免元素种类转换

const array = [3, 2, 1, +0];
// PACKED_SMI_ELEMENTS
array.push(-0);
// PACKED_DOUBLE_ELEMENTS

避免 -0,除非你需要在代码中明确区分 -0+0。(你可能并不需要)

同样还有 NaNInfinity。它们被表示为双精度,因此添加一个 NaNInfinity 会将 SMI_ELEMENTS 转换为
DOUBLE_ELEMENTS

如果您计划对整数数组执行大量操作,在初始化的时候请考虑规范化 -0,并且防止 NaN 以及 Infinity。这样数组就会保持 PACKED_SMI_ELEMENTS

6.3避免多态

如果您的代码需要处理包含多种不同元素类型的数组,则可能会比单个元素类型数组要慢,因为你的代码要对不同类型的数组元素进行多态操作。

const each = (array, callback) => {
  for (let index = 0; index < array.length; ++index) {
    const item = array[index];
    callback(item);
  }
};
const doSomething = (item) => console.log(item);


each([1, 2, 3], doSomething);
each([1.1, 2.2, 3.3], doSomething);
each(['a', 'b', 'c'], doSomething);

我们调用了each3次,并且每次都没有给它相同的元素类型,在V8中,它采用内联缓存(Inline Caches,简称 IC)来缓存调用的实现以优化这些操作的执行过程。
当我们第一次只传入类型为packed_smi_element[1,2,3],v8会使用IC来缓存这个方法的调用,记录元素类型以及其他信息,那么我们下一次传入packed_smi_element时,直接就可以从缓存里取到优化后的调用方法,然后进行调用。
但是我们第二次如果传入的不一样的元素类型,比如packed_double_number,那么v8又会重新缓存一个新的调用实现(适用于packed_double_number),那么我们传入元素的时候就需要进行2次判断了,先判断是不是smi,如果不是,就判断是不是packed_double_number,如果是其他,那么又会重新缓存一个新的调用实现...

这上面说的其实是多态IC,多态IC缓存数也是有上限的

6.4 类数组对象

类数组对象类似于数组,都有这数字属性和lenth属性,而且我们也可以通过call,apply的方式来让类数组对象使用数组方法

但是,这比在真正的数组中调用数组方法慢,数组的方法在 V8 中是高度优化的

所以,如果你打算对类数组对象(比如Dom,或者是arguments)进行操作,请先将其转换为数组对象

可以使用es6的语法或者Array的slice方法

Array.from(arrayLike)

Array.prototype.slice.call(arrayLike,0)

总结

主要有以下几点知识需要记住

  • 数字在js中是64位双精度浮点表示法表示的,但是V8内部做了优化,还可以使用32位来表示部分的整数
  • v8把数字分为小整数Smi和堆数字HeapNumber,Smi可以进行快速整型操作,比HeapNumber
  • 数组会将属性进行分类,并且称之为元素种类
  • 数组的元素种类只可以从比较特定的方向转变为比较普遍的方向,并且后者的效率会低得多

个人博客:李雷的博客
参考文章:
Mathias Bynens - V8 internals for JavaScript developers

The story of a V8 performance cliff in React

查看原文

赞 16 收藏 10 评论 0

李大雷 赞了文章 · 7月2日

Quill编辑器插入自定义HTML记录

转眼已经2020年,饥渴的人类不再满足于简单的文本,于是有了花里胡哨的携带各种样式的文本,然而有文本还不够,我们还需要让用户在编辑的时候,能够插入各种自定义消息类型,让我们发出去的软文更加好看,因此有了这篇文章。

前言

由于Quill编辑器自带的富文本过滤(大部分主流编辑器都会对富文本进行过滤处理),导致开发者想要配置自定义HTML模板时,遇到了不少麻烦。

一、Quill渲染逻辑分析

为了自定义Quill中的HTML块内容,首先需要了解Quill内部的渲染流程,这里有几个关键的概念需要了解:

1、Delta

Delta是Quill内部定义的一个数据格式,用于表示文档内容以及文档修改操作,易读且格式简单,通过Delta的形式来维护文档内容,HTML内容和Delta两者可以相互转化。

举个例子:
image.png
这样一段富文本会被表示成以下的格式:

{  
"ops":[ 
{"insert":"this is a simple text.\\nbut when "}, 
{"attributes":{"bold":true},"insert":"it is "}, 
{"insert":"not bold.\\nlet me try "}, 
{"attributes":{"italic":true},"insert":"italic "}, 
{"insert":"haha\\nwhat about "}, {"attributes": 
{"italic":true,"bold":true},"insert":"both"}, 
{"insert":" ?\\n"} ]  
}"

普通的文本会被定义成一个个的insert动作,每一项代表这一个delta,都是对文本内容的描述。

类似的,如果修改和删除也会生成对应的delta,之后会将新生成的change delta,与原有的delta进行合并操作,生成新的delta。(delta中一共包含三种操作:insert、delete、retain)

保留前10个字符,对后续的20个字符进行加粗操作的delta如下:

{
  "ops": [
    { "retain":  },
    { "retain": , "attributes": { "bold":  } }
  ]
}

保留前10个字符,对后续的20个字符进行删除操作如下:

{
  "ops": [
    { "retain":  },
    { "delete":  }
  ]
}

2、Parchment

Parchment是抽象的文档模型,对Blot进行管理。
将Parchment理解成完整的DOM树结构的话,那么Blot就是其中一个个单一的节点。而Blot去了Quill中默认的以外,还允许我们进行自定义,给了更大的扩展空间。

3、Blot

Blot是Parchment文档的组成部分,相当于对DOM节点类型的抽象,而一个具体的Blot实例里仍有其他的节点信息。

全局的根节点Blot是由Quill内部自定义的Scroll类型Blot,管理其下面的所有Blot。

对于Blot的实现定义可以参照这里:https://github.com/quilljs/parchment#blots

Quill中默认定义的Blot如下:
image.png
这其中常见的包括TextBlot(行内普通文本)、Inline(行内携带样式的普通文本)、Block(块级行,一般以段落p为单位)、Break(换行)、Image(图片IMG插入)、Bold(加粗文本)。

而一段HTML如何构建出Blot?Quill中会根据节点类型优先排除文本节点,如果是元素节点会根据节点的ClassName进行再次判断,如果仍然无法找到匹配的BlotName,则默认匹配以下的映射关系,来找到对应的BlotClass。
image.png

4、Delta的实际意义

既然已经有Blot可以来表示我们的内容结构了,为什么还需要Delta?Delta本身只是一份内容数据的维护,也就是说HTML的更新,无论是用户输入,还是API操作,都会同步更新到Delta中,而Delta如果不作为HTML的数据源的话,那么维护一份Delta数据的意义又在哪里?

如果HTML => Delta,而不存在Delta=>HTML,那么不停地去维护一份delta的意义是什么?

1、由Delta生成HTML其实是存在的,只不过应用场景只限于初始化文档的时候,Quill会对传入的初始化HTML字符串进行解析处理,生成对应的Delta,其次通过applyDelta的方式,生成DOM节点回显与页面中。

2、看到这里你可能还不满意,为啥非要走这一步流程,初始化的时候直接一段字符串document.getElementById('container').innerHTML = val不行吗,是的,可以,但是Delta的存在让用户的文档变得粒度更细小,变得易维护,变得可追溯。假如A和B同时编辑着一份文档,A删除了第二行的10个字符,不需要将文档内容全量更新,只需要提交action操作,同步自己的行为,而B这边也只需要进行冲突处理后merge即可。虽然Delta的维护让逻辑变得复杂了不少,但它的存在也让文档有了更多扩展的可能。

5、编辑器渲染与更新流程

对于内容的修改一共有以下3种方式:

1、初始化编辑器内容:初始化调用quill.pasteHTML,经过HTML过滤和解析回显到编辑框中。

2、Input Event:用户输入和编辑操作,通过MutationObserver监听处理,更新delta。

3、API调用:调用内部提供API,通过modify方法,而后调用全局Scroll实例的方法去修改。
image.png

二、插入自定义HTML块

由于文章内容越来越多样化,在文章插入地图、音乐播放器、广告面板等需求的存在,让我们需要对富文本编辑器扩展出更多的功能。但是同时也要做好xss防护攻击。

按照第一部分的讲述,我们需要插入一个自定义HTML块,同时又要Quill能够识别,聪明的你一定想到了,我们需要自定义一个Blot。通过定义好Blot的方式,让Quill在初始化的时候能够识别我们的HTML块展示,同时也让我们在插入HTML块的时候不会被Quill进行脏HTML过滤。

注册Blot方法如下:

export default function (Quill) {
  // 引入源码中的BlockEmbed
  const BlockEmbed = Quill.import('blots/block/embed');
  // 定义新的blot类型
  class AppPanelEmbed extends BlockEmbed {
    static create(value) {
      const node = super.create(value);
      node.setAttribute('contenteditable', 'false');
      node.setAttribute('width', '100%');
      //   设置自定义html
      node.innerHTML = this.transformValue(value)
      return node;
    }

    static transformValue(value) {
      let handleArr = value.split('\n')
      handleArr = handleArr.map(e => e.replace(/^[\s]+/, '')
        .replace(/[\s]+$/, ''))
      return handleArr.join('')
    }

    // 返回节点自身的value值 用于撤销操作
    static value(node) {
      return node.innerHTML
    }
  }
  // blotName
  AppPanelEmbed.blotName = 'AppPanelEmbed';
  // class名将用于匹配blot名称
  AppPanelEmbed.className = 'embed-innerApp';
  // 标签类型自定义
  AppPanelEmbed.tagName = 'div';
  Quill.register(AppPanelEmbed, true);
}

接下来你只需要这样调用,便可以在编辑器中插入自定义的HTML块:

quill.insertEmbed(quill.getSelection().index || 0, 'AppPanelEmbed', `
          <div class="app_card_header">     
              自定义面板标题
          </div>
          <div class="app_card_content">     
              自定义面板内容
          </div>
          <div class="app_card_footer">     
              footer
          </div>
      `);

传参格式要求如下:

insertEmbed(index: Number, type: String, value: any, source: String \= 'api'): Delta

这里仅仅这是个简单的示例,如果想丰富自定义Blot的功能,可以参照:https://github.com/quilljs/parchment#blots

由于contenteditable属性放开,为了防止造成xss攻击,所以需要我们对该属性做特殊的过滤处理,这里以xss模块处理为例:

handleWithXss(content) {
      const options = {
        whiteList: {
         ...
          div: ['class', 'style', 'data-id','contenteditable'],
         ...
        },
        css: {
          whiteList: {
            color: true,
            'background-color': true,
            'max-width': true,
          },
        },
        stripIgnoreTag: true,
        onTagAttr: (tag, name, value, isWhiteAttr) => {
          // 针对div的contenteditable 处理
          if (isWhiteAttr && tag === 'div' && name === 'contenteditable') {
            return 'contenteditable="false"';
          }
        },
      } // 自定义规则
      const myxss = new xss.FilterXSS(options)
      return myxss.process(content)
    }

到这里,就大功告成啦~

感谢观看~

查看原文

赞 8 收藏 5 评论 3

李大雷 发布了文章 · 6月23日

如何搭建个人博客网站【附开源代码】

前言

最近个人博客打算做一次比较大的更新,所以把第一版开源了。顺便写个文章记录一下搭建博客的一些注意项

1.网站模块

大概有一下这些(从各处个人网站观察得到)

  • 主页展示
  • 个人介绍
  • 留言板
  • 心情记录
  • 博文归档
  • 作品展示
  • 商业合作
  • 书籍分享

我目前是只选择了主页综合展示,书籍分享,心情记录,个人介绍4个模块。没有专门做一个博文的归档,是因为我考虑到博文数量不多,于是就把这一部分整合到主页,使用无限滚动的方式来展示博文,如果文章比较多的话还是推荐专门分开一个模块做文章的检索分类。

2.挑选技术栈

其实选vue的话,我可以少花很多时间hhh。因为学了react,然后公司的项目又用不上,所以就拿个博客来练手了,写法比较稚嫩,希望各位大佬见谅。
使用到的技术大概有这些react,redux,axios,sass

3.需要实现的功能和注意点

  1. 文章浏览
    首先最主要的肯定是能看文章嘛,那么如何记录文章,如何排版文章呢?我一开始是想使用富文本编辑器的,直接输出html保存到数据库,然后前台直接读即可。后面考虑到,如果这样的话,我就要sf上所有的文章自己在富文本编辑器上重新排版编辑一遍,那就太浪费时间了。所以我选择了直接使用markdown语法来编辑文章,然后使用react-markdown插件来解析我的文章,最终在前端进行展示。
  2. 文章评论
    一开始是想做楼中楼回复,可以进行多级评论, 后面考虑到第一:实现起来会麻烦很多,后台的数据库设计也比较复杂。第二:我这个是个人博客网站,没必要进行多人互相讨论。所以,只做了一级评论,也就是每次我回复别人也算是一条新的评论。
  3. 访客记录
    因为是个人网站,所以没有设计用户系统,想想别人发表评论还需要登陆之类的操作是多麻烦。那发表评论怎么记录是谁发表的呢?我还是提供了一个表单来填写发言人的名字的。这个名字仅仅是储存在前台的localstorage里,讲道理也没有人会一直清理这个东西,所以你下次来我的网站,就记住你了。
  4. 文章阅读和点赞等
    因为没有用户系统,所以阅读数应该是每次点进来都要发请求记录一次的,这个问题不大。是否已经点赞的话,这些信息都可以储存在前端的localStorage里,记录一下文章id即可,下次点进这个文章就知道是否赞过了
  5. 订阅|消息推送
    消息推送的话,我使用的是node.js接入第三方的邮件服务来发送邮件通知订阅者。除了发文章要通知订阅者,有访客评论的时候最好也搞个右键通知我们自己

4.页面适配

现在手机那么多人用啦,讲道理确实得给手机适配一下,手机端我去掉了很多的模块,主要留下了文章列表,而且是使用媒体查询来进行页面适配的,为什么要使用媒体查询?

  • 因为我要1px,1px地精准调节来达到移动端比较好的展示效果
  • 因为我不需要适配太多设备,一种手机,一种pc即可,手机屏幕尺寸不做太多细致的处理,因为现在大部分人的手机尺寸都是全面屏6寸这样,看我博客的人显然不会是用着安卓2.3的70岁的阿伯,基于网站的受众分析,只需要做这两种尺寸的适配

页面适配说了,那么页面设计呢?
设计这玩意儿见仁见智,如果懂设计,那就自己设计,如果不懂,那就抄,就是这么简单

5.后台管理

当我们写好前端的博客以后,并且挂在服务器上的时候,肯定不想每次写文章,用xx笔记写好,然后手动insert进服务器,这就比较傻了。所以我们肯定还需要一个后台管理,来管理我们的博文,留言,评论等等

目前我的博客有的管理项目如下,可以参考下:

  • 标签管理
  • 博文管理
  • 评论管理
  • 订阅管理
  • 音乐管理
  • 书籍管理
  • 心情管理

6.后台服务

我选用了Node.js来写后台服务,整体比较简单,需要注意的有两个点.

  • 接口权限
    我们要对请求进行拦截,并判断有无携带cookie信息来决定是否进行跳转。前台的权限应该是仅限于数据库操作的,后台管理是可以进行增删改查的,我们可以建立一个白名单来辨别这两类接口,然后进行相应的拦截操作或者跳转即可。
  • 数据库操作
    我没有使用适合前端的mongoDB,而是使用了mysql,为什么?因为前面也说了,这个是练手的项目,所以我就没有用那么常规的mongoDB...确实是有些蛋疼。使用mysql的话,直接拼接sql语句那肯定是非常繁琐和复杂的,我使用了一个ORM框架,非常好用,可以像使用mongoDB那样使用mysql。可以看我之前的博文纵享丝滑TYPEORM

后语

说了那么多,不如直接上源码,各位老爷,给个star可好?
github博客源码

实际的博客(已经大更新了,用vue重构了)
leelei的博客

查看原文

赞 6 收藏 4 评论 8

李大雷 赞了文章 · 6月23日

你不知道的 TypeScript 泛型(万字长文,建议收藏)

泛型是 TypeScript(以下简称 TS) 比较高级的功能之一,理解起来也比较困难。泛型应用场景非常广泛,很多地方都能看到它的影子。平时我们阅读开源 TS 项目源码,或者在自己的 TS 项目中使用一些第三方库(比如 React)的时候,经常会看到各种泛型定义。如果你不是特别了解泛型,那么你很可能不仅不会用,不会实现,甚至看不懂这是在干什么。

相信大家都经历过,看到过,或者正在写一些应用,这些应用充斥着各种重复类型定义, any 类型层出不穷,鼠标移到变量上面的提示只有 any,不要说类型操作了,类型能写对都是个问题。我也经历过这样的阶段,那个时候我对 TS 还比较陌生。

随着在 TS 方面学习的深入,越来越认识到 真正的 TS 高手都是在玩类型,对类型进行各种运算生成新的类型。这也好理解,毕竟 TS 提供的其实就是类型系统。你去看那些 TS 高手的代码,会各种花式使用泛型。 可以说泛型是一道坎,只有真正掌握它,你才知道原来 TS 还可以这么玩。怪不得面试的时候大家都愿意问泛型,尽管面试官很可能也不怎么懂。

只有理解事物的内在逻辑,才算真正掌握了,不然永远只是皮毛,不得其法。 本文就带你走进泛型,带你从另一个角度看看究竟什么是泛型,为什么要有它,它给 TS 带来了什么样的不同。

注意:不同语言泛型略有不同,知识迁移虽然可以,但是不能生搬硬套,本文所讲的泛型都指的是 TS 下的泛型。

引言

我总结了一下,学习 TS 有两个难点。第一个是TS 和 JS 中容易混淆的写法,第二个是TS中特有的一些东西

  • TS 中容易引起大家的混淆的写法

比如:

容易混淆的箭头函数

(容易混淆的箭头函数)

再比如:

(容易混淆的 interface 内的小括号)

  • TS 中特有的一些东西

比如 typeof,keyof, infer 以及本文要讲的泛型。

把这些和 JS 中容易混淆的东西分清楚,然后搞懂 TS 特有的东西,尤其是泛型(其他基本上相对简单),TS 就入门了。

泛型初体验

在强类型语言中,一般而言需要给变量指定类型才能使用该变量。如下代码:

const name: string = "lucifer";
console.log(name);

我们需要给 name 声明 string 类型,然后才能在后面使用 name 变量,当我们执行以下操作的时候会报错。

  • 给 name 赋其他类型的值
  • 使用其他类型值特有的方法(比如 Number 类型特有的 toFixed)
  • 将 name 以参数传给不支持 string 的函数。 比如 divide(1, name),其中 divide 就是功能就是将第一个数(number 类型)除以第二个数(number 类型),并将结果返回

TS 除了提供一些基本类型(比如上面的 string)供我们直接使用。还:

  • 提供了 intefacetype 关键字供我们定义自己的类型,之后就能像使用基本类型一样使用自己定义的类型了。
  • 提供了各种逻辑运算符,比如 &, | 等 ,供我们对类型进行操作,从而生成新的类型。
  • 提供泛型,允许我们在定义的时候不具体指定类型,而是泛泛地说一种类型,并在函数调用的时候再指定具体的参数类型。
  • 。。。

也就是说泛型也是一种类型,只不过不同于 string, number 等具体的类型,它是一种抽象的类型,我们不能直接定义一个变量类型为泛型。

简单来说,区别于平时我们对进行编程,泛型是对类型进行编程。这个听起来比较抽象。之后我们会通过若干实例带你理解这句话,你先留一个印象就好。

为了明白上面这句话,·首先要区分“值”和“类型”。

值和类型

我们平时写代码基本都是对值编程。比如:

if (person.isVIP) {
    console.log('VIP')
}
if (cnt > 5) {
    // do something
}

const personNames = persons.map(p => p.name)
...

可以看出这都是对具体的值进行编程,这符合我们对现实世界的抽象。从集合论的角度上来说, 值的集合就是类型,在 TS 中最简单的用法是对值限定类型,从根本上来说是限定值的集合。这个集合可以是一个具体的集合,也可以是多个集合通过集合运算(交叉并)生成的新集合。

(值和类型)

再来看一个更具体的例子:

function t(name: string) {
  return `hello, ${name}`;
}
t("lucifer");

字符串 "lucifer" 是 string 类型的一个具体。 在这里 "lucifer" 就是值,而 string 就是类型。

TS 明白 "lucifer" 是 string 集合中的一个元素,因此上面代码不会有问题,但是如果是这样就会报错:

t(123);

因为 123 并不是 string 集合中的一个元素。

对于 t("lucifer")而言,TS 判断逻辑的伪代码:

v = getValue(); // will return 'lucifer' by ast
if (typeof v === "string") {
  // ok
} else {
  throw "type error";
}
由于是静态类型分析工具,因此 TS 并不会执行 JS 代码,但并不是说 TS 内部没有执行逻辑。

简单来总结一下就是: 值的集合就是类型,平时写代码基本都是对值编程,TS 提供了很多类型(也可以自定义)以及很多类型操作帮助我们限定值以及对值的操作

什么是泛型

上面已经铺垫了一番,大家已经知道了值和类型的区别,以及 TS 究竟帮我们做了什么事情。但是直接理解泛型仍然会比较吃力,接下来我会通过若干实例,慢慢带大家走进泛型。

首先来思考一个问题:为什么要有泛型呢?这个原因实际上有很多,在这里我选择大家普遍认同的一个切入点来解释。如果你明白了这个点,其他点相对而言理解起来会比较轻松。还是通过一个例子来进行说明。

不容小觑的 id 函数

假如让你实现一个函数 id,函数的参数可以是任何值,返回值就是将参数原样返回,并且其只能接受一个参数,你会怎么做?

你会觉得这很简单,顺手就写出这样的代码:

const id = (arg) => arg;
有的人可能觉得 id 函数没有什么实际作用。其实不然, id 函数在函数式编程中应用非常广泛。

由于其可以接受任意值,也就是说你的函数的入参和返回值都应该可以是任意类型。 现在让我们给代码增加类型声明:

type idBoolean = (arg: boolean) => boolean;
type idNumber = (arg: number) => number;
type idString = (arg: string) => string;
...

一个笨的方法就像上面那样,也就是说 JS 提供多少种类型,就需要复制多少份代码,然后改下类型签名。这对程序员来说是致命的。这种复制粘贴增加了出错的概率,使得代码难以维护,牵一发而动全身。并且将来 JS 新增新的类型,你仍然需要修改代码,也就是说你的代码对修改开放,这样不好。还有一种方式是使用 any 这种“万能语法”。缺点是什么呢?我举个例子:

id("string").length; // ok
id("string").toFixed(2); // ok
id(null).toString(); // ok
...

如果你使用 any 的话,怎么写都是 ok 的, 这就丧失了类型检查的效果。实际上我知道我传给你的是 string,返回来的也一定是 string,而 string 上没有 toFixed 方法,因此需要报错才是我想要的。也就是说我真正想要的效果是:当我用到id的时候,你根据我传给你的类型进行推导。比如我传入的是 string,但是使用了 number 上的方法,你就应该报错。

为了解决上面的这些问题,我们使用泛型对上面的代码进行重构。和我们的定义不同,这里用了一个 类型 T,这个 T 是一个抽象类型,只有在调用的时候才确定它的值,这就不用我们复制粘贴无数份代码了。

function id<T>(arg: T): T {
  return arg;
}

为什么这样就可以了? 为什么要用这种写法?这个尖括号什么鬼?万物必有因果,之所以这么设计泛型也是有原因的。那么就让我来给大家解释一下,相信很多人都没有从这个角度思考过这个问题。

泛型就是对类型编程

上面提到了一个重要的点 平时我们都是对值进行编程,泛型是对类型进行编程。上面我没有给大家解释这句话。现在铺垫足够了,那就让我们开始吧!

继续举一个例子:假如我们定义了一个 Person 类,这个 Person 类有三个属性,并且都是必填的。这个 Person 类会被用于用户提交表单的时候限定表单数据。

enum Sex {
  Man,
  Woman,
  UnKnow,
}
interface Person {
  name: string;
  sex: Sex;
  age: number;
}

突然有一天,公司运营想搞一个促销活动,也需要用到 Person 这个 shape,但是这三个属性都可以选填,同时要求用户必须填写手机号以便标记用户和接受短信。一个很笨的方法是重新写一个新的类:

interface MarketPerson {
  name?: string;
  sex?: Sex;
  age?: number;
  phone: string;
}
还记得我开头讲的重复类型定义么? 这就是!

这明显不够优雅。如果 Person 字段很多呢?这种重复代码会异常多,不利于维护。 TS 的设计者当然不允许这么丑陋的设计存在。那么是否可以根据已有类型,生成新的类型呢?当然可以!答案就是前面我提到了两种对类型的操作:一种是集合操作,另一种是今天要讲的泛型。

先来看下集合操作:

type MarketPerson = Person & { phone: string };

这个时候我们虽然添加了一个必填字段 phone,但是没有做到name, sex, age 选填,似乎集合操作做不到这一点呀。我们脑洞一下,假如我们可以像操作函数那样操作类型,是不是有可能呢?比如我定义了一个函数 Partial,这个函数的功能入参是一个类型,返回值是新的类型,这个类型里的属性全部变成可选的。

伪代码:


function Partial(Type) {
    type ans = 空类型
    for(k in Type) {
        空类型[k]  = makeOptional(Type, k)
    }
    return ans
}

type PartialedPerson = Partial(Person)

可惜的是上面代码不能运行,也不可能运行。不可能运行的原因有:

  • 这里使用函数 Partial 操作类型,可以看出上面的函数我是没有添加签名的,我是故意的。如果让你给这个函数添加签名你怎么加?没办法加!
  • 这里使用 JS 的语法对类型进行操作,这是不恰当的。首先这种操作依赖了 JS 运行时,而 TS 是静态分析工具,不应该依赖 JS 运行时。其次如果要支持这种操作是否意味者 TS 对 JS 妥协,JS 出了新的语法(比如早几年出的 async await),TS 都要支持其对 TS 进行操作。

因此迫切需要一种不依赖 JS 行为,特别是运行时行为的方式,并且逻辑其实和上面类似的,且不会和现有语法体系冲突的语法。 我们看下 TS 团队是怎么做的:

// 可以看成是上面的函数定义,可以接受任意类型。由于是这里的 “Type” 形参,因此理论上你叫什么名字都是无所谓的,就好像函数定义的形参一样。
type Partial<Type> = { do something }
// 可以看成是上面的函数调用,调用的时候传入了具体的类型 Person
type PartialedPerson = Partial<Person>

先不管功能,我们来看下这两种写法有多像:

(定义)

(运行)

再来看下上面泛型的功能。上面代码的意思是对 T 进行处理,是返回一个 T 的子集,具体来说就是将 T 的所有属性变成可选。这时 PartialedPerson 就等于 :

interface Person {
  name?: string;
  sex?: Sex;
  age?: number;
}
功能和上面新建一个新的 interface 一样,但是更优雅。

最后来看下泛型 Partial<Type> 的具体实现,可以看出其没有直接使用 JS 的语法,而是自己定义了一套语法,比如这里的 keyof,至此完全应证了我上面的观点。

type Partial<T> = { [P in keyof T]?: T[P] };
刚才说了“由于是形参,因此起什么名字无所谓” 。因此这里就起了 T 而不是 Type,更短了。这也算是一种约定俗称的规范,大家一般习惯叫 T, U 等表示泛型的形参。

我们来看下完整的泛型和函数有多像!

(定义)

(使用)

  • 从外表看只不过是 function 变成了 type() 变成了 <>而已。
  • 从语法规则上来看, 函数内部对标的是 ES 标准。而泛型对应的是 TS 实现的一套标准。

简单来说,将类型看成值,然后对类型进行编程,这就是泛型的基本思想。泛型类似我们平时使用的函数,只不过其是作用在类型上,思想上和我们平时使用的函数并没有什么太多不同,泛型产生的具体类型也支持类型的操作。比如:

type ComponentType<P = {}> = ComponentClass<P> | FunctionComponent<P>;

有了上面的知识,我们通过几个例子来巩固一下。

function id<T, U>(arg1: T, arg2: U): T {
  return arg1;
}

上面定义了泛型 id,其入参分别是 T 和 U,和函数参数一样,使用逗号分隔。定义了形参就可以在函数体内使用形参了。如上我们在函数的参数列表和返回值中使用了形参 T 和 U。

返回值也可以是复杂类型:

function ids<T, U>(arg1: T, arg2: U): [T, U] {
  return [arg1, arg2];
}

(泛型的形参)

和上面类似, 只不过返回值变成了数组而已。

需要注意的是,思想上我们可以这样去理解。但是具体的实现过程会有一些细微差别,比如:

type P = [number, string, boolean];
type Q = Date;

type R = [Q, ...P]; // A rest element type must be an array type.

再比如:

type Lucifer = LeetCode;
type LeetCode<T = {}> = {
  name: T;
};

const a: LeetCode<string>; //ok
const a: Lucifer<string>; // Type 'Lucifer' is not generic.

改成这样是 ok 的:

type Lucifer<T> = LeetCode<T>;

泛型为什么使用尖括号

为什么泛型要用尖括号(<>),而不是别的? 我猜是因为它和 () 长得最像,且在现在的 JS 中不会有语法歧义。但是,它和 JSX 不兼容!比如:

function Form() {
  // ...

  return (
    <Select<string> options={targets} value={target} onChange={setTarget} />
  );
}

这是因为 TS 发明这个语法的时候,还没想过有 JSX 这种东西。后来 TS 团队在 TypeScript 2.9 版本修复了这个问题。也就是说现在你可以直接在 TS 中使用带有泛型参数的 JSX 啦(比如上面的代码)。

泛型的种类

实际上除了上面讲到的函数泛型,还有接口泛型和类泛型。不过语法和含义基本同函数泛型一样:

interface id<T, U> {
  id1: T;
  id2: U;
}

(接口泛型)

class MyComponent extends React.Component<Props, State> {
   ...
}

(类泛型)

总结下就是: 泛型的写法就是在标志符后面添加尖括号(<>),然后在尖括号里写形参,并在 body(函数体, 接口体或类体) 里用这些形参做一些逻辑处理。

泛型的参数类型 - “泛型约束”

正如文章开头那样,我们可以对函数的参数进行限定。

function t(name: string) {
  return `hello, ${name}`;
}
t("lucifer");

如上代码对函数的形参进行了类型限定,使得函数仅可以接受 string 类型的值。那么泛型如何达到类似的效果呢?

type MyType = (T: constrain) => { do something };

还是以 id 函数为例,我们给 id 函数增加功能,使其不仅可以返回参数,还会打印出参数。熟悉函数式编程的人可能知道了,这就是 trace 函数,用于调试程序。

function trace<T>(arg: T): T {
  console.log(arg);
  return arg;
}

假如我想打印出参数的 size 属性呢?如果完全不进行约束 TS 是会报错的:

注意:不同 TS 版本可能提示信息不完全一致,我的版本是 3.9.5。下文的所有测试结果均是使用该版本,不再赘述。
function trace<T>(arg: T): T {
  console.log(arg.size); // Error: Property 'size doesn't exist on type 'T'
  return arg;
}

报错的原因在于 T 理论上是可以是任何类型的,不同于 any,你不管使用它的什么属性或者方法都会报错(除非这个属性和方法是所有集合共有的)。那么直观的想法是限定传给 trace 函数的参数类型应该有 size 类型,这样就不会报错了。如何去表达这个类型约束的点呢?实现这个需求的关键在于使用类型约束。 使用 extends 关键字可以做到这一点。简单来说就是你定义一个类型,然后让 T 实现这个接口即可。

interface Sizeable {
  size: number;
}
function trace<T extends Sizeable>(arg: T): T {
  console.log(arg.size);
  return arg;
}

这个时候 T 就不再是任意类型,而是被实现接口的 shape,当然你也可以继承多个接口。类型约束是非常常见的操作,大家一定要掌握。

有的人可能说我直接将 Trace 的参数限定为 Sizeable 类型可以么?如果你这么做,会有类型丢失的风险,详情可以参考这篇文章A use case for TypeScript Generics

常见的泛型

集合类

大家平时写 TS 一定见过类似 Array<String> 这种写法吧? 这其实是集合类,也是一种泛型。

本质上数组就是一系列值的集合,这些值可以可以是任意类型,数组只是一个容器而已。然而平时开发的时候通常数组的项目类型都是相同的,如果不加约束的话会有很多问题。 比如我应该是一个字符串数组,然是却不小心用到了 number 的方法,这个时候类型系统应该帮我识别出这种类型问题

由于数组理论可以存放任意类型,因此需要使用者动态决定你想存储的数据类型,并且这些类型只有在被调用的时候才能去确定。 Array<String> 就是调用,经过这个调用会产生一个具体集合,这个集合只能存放 string 类型的值。

不调用直接把 Array 是不被允许的:

const a: Array = ["1"];

如上代码会被错:Generic type 'Array<T>' requires 1 type argument(s).ts 。 有没有觉得和函数调用没传递参数报错很像?像就对了。

这个时候你再去看 Set<number>, Promise<string>,是不是很快就知道啥意思了?它们本质上都是包装类型,并且支持多种参数类型,因此可以用泛型来约束。

React.FC

大家如果开发过 React 的 TS 应用,一定知道 React.FC 这个类型。我们来看下它是如何定义的:

type FC<P = {}> = FunctionComponent<P>;

interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
  propTypes?: WeakValidationMap<P>;
  contextTypes?: ValidationMap<any>;
  defaultProps?: Partial<P>;
  displayName?: string;
}

可以看出其大量使用了泛型。你如果不懂泛型怎么看得懂呢?不管它多复杂,我们从头一点点分析就行,记住我刚才讲的类比方法,将泛型类比到函数进行理解。·

  • 首先定义了一个泛型类型 FC,这个 FC 就是我们平时用的 React.FC。它是通过另外一个泛型 FunctionComponent 产生的。
因此,实际上第一行代码的作用就是起了一个别名
  • FunctionComponent 实际上是就是一个接口泛型,它定义了五个属性,其中四个是可选的,并且是静态类属性。
  • displayName 比较简单,而 propTypes,contextTypes,defaultProps 又是通过其他泛型生成的类型。我们仍然可以采用我的这个分析方法继续分析。由于篇幅原因,这里就不一一分析,读者可以看完我的分析过程之后,自己尝试分析一波。
  • (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null; 的含义是 FunctionComponent 是一个函数,接受两个参数(props 和 context )返回 ReactElement 或者 null。ReactElement 大家应该比较熟悉了。PropsWithChildren 实际上就是往 props 中插入 children,源码也很简单,代码如下:
type PropsWithChildren<P> = P & { children?: ReactNode };

这不就是我们上面讲的集合操作可选属性么?至此,React.FC 的全貌我们已经清楚了。读者可以试着分析别的源码检测下自己的学习效果,比如 React.useState 类型的签名。

类型推导与默认参数

类型推导和默认参数是 TS 两个重要功能,其依然可以作用到泛型上,我们来看下。

类型推导

我们一般常见的类型推导是这样的:

const a = "lucifer"; // 我们没有给 a 声明类型, a 被推导为 string
a.toFixed(); // Property 'toFixed' does not exist on type 'string'.
a.includes("1"); // ok

需要注意的是,类型推导是仅仅在初始化的时候进行推导,如下是无法正确推导的:

let a = "lucifer"; // 我们没有给 a 声明类型, a 被推导为string
a.toFixed(); // Property 'toFixed' does not exist on type 'string'.
a.includes("1"); // ok
a = 1;
a.toFixed(); // 依然报错, a 不会被推导 为 number

而泛型也支持类型推导,以上面的 id 函数为例:

function id<T>(arg: T): T {
  return arg;
}
id<string>("lucifer"); // 这是ok的,也是最完整的写法
id("lucifer"); // 基于类型推导,我们可以这样简写

这也就是为什么 useState 有如下两种写法的原因。

const [name, setName] = useState("lucifer");
const [name, setName] = useState<string>("lucifer");

实际的类型推导要更加复杂和智能。相信随着时间的推进,TS 的类型推导会更加智能。

默认参数

类型推导相同的点是,默认参数也可以减少代码量,让你少些代码。前提是你要懂,不然伴随你的永远是大大的问号。其实你完全可以将其类比到函数的默认参数来理解。

举个例子:

type A<T = string> = Array<T>;
const aa: A = [1]; // type 'number' is not assignable to type 'string'.
const bb: A = ["1"]; // ok
const cc: A<number> = [1]; // ok

上面的 A 类型默认是 string 类型的数组。你可以不指定,等价于 Array<string>,当然你也可以显式指定数组类型。有一点需要注意:在 JS 中,函数也是值的一种,因此:

const fn = () => null; // ok

但是泛型这样是不行的,这是和函数不一样的地方(设计缺陷?Maybe):

type A = Array; // error: Generic type 'Array<T>' requires 1 type argument(s).

其原因在与 Array 的定义是:

interface Array<T> {
    ...
}

而如果 Array 的类型也支持默认参数的话,比如:

interface Array<T = string> {
    ...
}

那么 type A = Array; 就是成立的,如果不指定的话,会默认为 string 类型。

什么时候用泛型

如果你认真看完本文,相信应该知道什么时候使用泛型了,我这里简单总结一下。

当你的函数,接口或者类:

  • 需要作用到很多类型的时候,比如我们介绍的 id 函数的泛型声明。
  • 需要被用到很多地方的时候,比如我们介绍的 Partial 泛型。

进阶

上面说了泛型和普通的函数有着很多相似的地方。普通的函数可以嵌套其他函数,甚至嵌套自己从而形成递归。泛型也是一样!

泛型支持函数嵌套

比如:

type CutTail<Tuple extends any[]> = Reverse<CutHead<Reverse<Tuple>>>;

如上代码中, Reverse 是将参数列表反转,CutHead 是将数组第一项切掉。因此 CutTail 的意思就是将传递进来的参数列表反转,切掉第一个参数,然后反转回来。换句话说就是切掉参数列表的最后一项。 比如,一个函数是 function fn (a: string, b: number, c: boolean):boolean {},那么经过操作type cutTailFn = CutTail<typeof fn>,可以返回(a: string, b:number) => boolean。 具体实现可以参考Typescript 复杂泛型实践:如何切掉函数参数表的最后一个参数?。 在这里,你知道泛型支持嵌套就够了。

泛型支持递归

泛型甚至可以嵌套自己从而形成递归,比如我们最熟悉的单链表的定义就是递归的。

type ListNode<T> = {
  data: T;
  next: ListNode<T> | null;
};

(单链表)

再比如 HTMLElement 的定义。

declare var HTMLElement: {
    prototype: HTMLElement;
    new(): HTMLElement;
};。

HTMLElement

上面是递归声明,我们再来看一个更复杂一点的递归形式 - 递归调用,这个递归调用的功能是:递归地将类型中所有的属性都变成可选。类似于深拷贝那样,只不过这不是拷贝操作,而是变成可选,并且是作用在类型,而不是值。

type DeepPartial<T> = T extends Function
  ? T
  : T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

type PartialedWindow = DeepPartial<Window>; // 现在window 上所有属性都变成了可选啦

TS 泛型工具及实现

虽然泛型支持函数的嵌套,甚至递归,但是其语法能力肯定和 JS 没法比, 想要实现一个泛型功能真的不是一件容易的事情。这里提供几个例子,看完这几个例子,相信你至少可以达到比葫芦画瓢的水平。这样多看多练,慢慢水平就上来了。

截止目前(2020-06-21),TS 提供了 16 种工具类型

(官方提供的工具类型)

除了官方的工具类型,还有一些社区的工具类型,比如type-fest,你可以直接用或者去看看源码看看高手是怎么玩类型的。

我挑选几个工具类,给大家讲一下实现原理

Partial

功能是将类型的属性变成可选。注意这是浅 Partial,DeepPartial 上面我讲过了,只要配合递归调用使用即可。

type Partial<T> = { [P in keyof T]?: T[P] };

Required

功能和Partial 相反,是将类型的属性变成必填, 这里的 -指的是去除。 -? 意思就是去除可选,也就是必填啦。

type Required<T> = { [P in keyof T]-?: T[P] };

Mutable

功能是将类型的属性变成可修改,这里的 -指的是去除。 -readonly 意思就是去除只读,也就是可修改啦。

type Mutable<T> = {
  -readonly [P in keyof T]: T[P];
};

Readonly

功能和Mutable 相反,功能是将类型的属性变成只读, 在属性前面增加 readonly 意思会将其变成只读。

type Readonly<T> = { readonly [P in keyof T]: T[P] };

ReturnType

功能是用来得到一个函数的返回值类型。

type ReturnType<T extends (...args: any[]) => any> = T extends (
  ...args: any[]
) => infer R
  ? R
  : any;

下面的示例用 ReturnType 获取到 Func 的返回值类型为 string,所以,foo 也就只能被赋值为字符串了。

type Func = (value: number) => string;

const foo: ReturnType<Func> = "1";

更多参考TS - es5.d.ts 这些泛型可以极大减少大家的冗余代码,大家可以在自己的项目中自定义一些工具类泛型。

Bonus - 接口智能提示

最后介绍一个实用的小技巧。如下是一个接口的类型定义:

interface Seal {
  name: string;
  url: string;
}
interface API {
  "/user": { name: string; age: number; phone: string };
  "/seals": { seal: Seal[] };
}
const api = <URL extends keyof API>(url: URL): Promise<API[URL]> => {
  return fetch(url).then((res) => res.json());
};

我们通过泛型以及泛型约束,实现了智能提示的功能。使用效果:

(接口名智能提示)

(接口返回智能提示)

原理很简单,当你仅输入 api 的时候,其会将 API interface 下的所有 key 提示给你,当你输入某一个 key 的时候,其会根据 key 命中 interface 定义的类型,然后给予类型提示。

总结

学习 Typescript 并不是一件简单的事情,尤其是没有其他语言背景的情况。而 TS 中最为困难的内容之一恐怕就是泛型了。

泛型和我们平时使用的函数是很像的,如果将两者进行横向对比,会很容易理解,很多函数的都关系可以迁移到泛型,比如函数嵌套,递归,默认参数等等。泛型是对类型进行编程,参数是类型,返回值是一个新的类型。我们甚至可以对泛型的参数进行约束,就类似于函数的类型约束。

最后通过几个高级的泛型用法以及若干使用的泛型工具类帮助大家理解和消化上面的知识。要知道真正的 TS 高手都是玩类型的,高手才不会满足于类型的交叉并操作。 泛型用的好确实可以极大减少代码量,提高代码维护性。如果用的太深入,也可能会团队成员面面相觑,一脸茫然。因此抽象层次一定要合理,不仅仅是泛型,整个软件工程都是如此。

大家也可以关注我的公众号《脑洞前端》获取更多更新鲜的前端硬核文章,带你认识你不知道的前端。

查看原文

赞 55 收藏 38 评论 8

李大雷 赞了文章 · 6月16日

这个前端竟然用动态规划写瀑布流布局?给我打死他!

前言

瀑布流布局是前端领域中一个很常见的需求,由于图片的高度是不一致的,所以在多列布局中默认布局下很难获得满意的排列。

我们的需求是,图片高度不规律的情况下,在两列布局中,让左右两侧的图片总高度尽可能的接近,这样的布局会非常的美观。

注意,本文的目的仅仅是讨论算法在前端中能如何运用,而不是说瀑布流的最佳解法是动态规划,可以仅仅当做学习拓展来看。

本文的图片节选自知乎问题《有个漂亮女朋友是种怎样的体验?》,我先去看美女了,本文到此结束。(逃

预览

分析

从预览图中可以看出,虽然图片的高度是不定的,但是到了这个布局的最底部,左右两张图片是正好对齐的,这就是一个比较美观的布局了。

那么怎么实现这个需求呢?从头开始拆解,现在我们能拿到一组图片数组 [img1, img2, img3],我们可以通过一些方法得到它对应的高度 [1000, 2000, 3000],那么现在我们的目标就是能够计算出左右两列 left: [1000, 2000]right: [3000] 这样就可以把一个左右等高的布局给渲染出来了。

准备工作

首先准备好小姐姐数组 SISTERS

let SISTERS = [
  'https://pic3.zhimg.com/v2-89735fee10045d51693f1f74369aaa46_r.jpg',
  'https://pic1.zhimg.com/v2-ca51a8ce18f507b2502c4d495a217fa0_r.jpg',
  'https://pic1.zhimg.com/v2-c90799771ed8469608f326698113e34c_r.jpg',
  'https://pic1.zhimg.com/v2-8d3dd83f3a419964687a028de653f8d8_r.jpg',
  ... more 50 items
]

准备好一个工具方法 loadImages,这个方法的目的就是把所有图片预加载以后获取对应的高度,放到一个数组里返回。并且要对外通知所有图片处理完成的时机,有点类似于 Promise.all 的思路。

这个方法里,我们把图片按照 宽高比 和屏幕宽度的一半进行相乘,得到缩放后适配屏宽的图片高度。

let loadImgHeights = (imgs) => {
  return new Promise((resolve, reject) => {
    const length = imgs.length
    const heights = []
    let count = 0
    const load = (index) => {
      let img = new Image()
      const checkIfFinished = () => {
        count++
        if (count === length) {
          resolve(heights)
        }
      }
      img.onload = () => {
        const ratio = img.height / img.width
        const halfHeight = ratio * halfInnerWidth
        // 高度按屏幕一半的比例来计算
        heights[index] = halfHeight
        checkIfFinished()
      }
      img.onerror = () => {
        heights[index] = 0
        checkIfFinished()
      }
      img.src = imgs[index]
    }
    imgs.forEach((img, index) => load(index))
  })
}

有了图片高度以后,我们就开始挑选适合这个需求的算法了。

贪心算法

在人的脑海中最直观的想法是什么样的?在每装一个图片前都对比一下左右数组的高度和,往高度较小的那个数组里去放入下一项。

这就是贪心算法,我们来简单实现下:

let greedy = (heights) => {
  let leftHeight = 0
  let rightHeight = 0
  let left = []
  let right = []

  heights.forEach((height, index) => {
    if (leftHeight >= rightHeight) {
      right.push(index)
      rightHeight += height
    } else {
      left.push(index)
      leftHeight += height
    }
  })

  return { left, right }
}

我们得到了 leftright 数组,对应左右两列渲染图片的下标,并且我们也有了每个图片的高度,那么渲染到页面上就很简单了:

<div class="wrap" v-if="imgsLoaded">
  <div class="half">
    <img
      class="img"
      v-for="leftIndex in leftImgIndexes"
      :data-original="imgs[leftIndex]"
      :style="{ width: '100%', height: imgHeights[leftIndex] + 'px' }"
    />
  </div>
  <div class="half">
    <img
      class="img"
      v-for="rightIndex in rightImgIndexes"
      :data-original="imgs[rightIndex]"
      :style="{ width: '100%', height: imgHeights[rightIndex] + 'px' }"
    />
  </div>
</div>

效果如图:

预览地址:
https://sl1673495.github.io/d...

可以看出,贪心算法只寻求局部最优解(只在考虑当前图片的时候找到一个最优解),所以最后左右两边的高度差还是相对较大的,局部最优解很难成为全局最优解。

再回到文章开头的图片去看看,对于同样的一个图片数组,那个预览图里的高度差非常的小,是怎么做到的呢?

动态规划

和局部最优解对应的是全局最优解,而说到全局最优解,我们很难不想到动态规划这种算法。它是求全局最优解的一个利器。

如果你还没有了解过动态规划,建议你看一下海蓝大佬的 一文搞懂动态规划,也是这篇文章让我入门了最基础的动态规划。

动态规划中有一个很著名的问题:「01 背包问题」,题目的意思是这样的:

有 n 个物品,它们有各自的体积和价值,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?

关于 01 背包问题的题解,网上不错的教程似乎不多,我推荐看慕课网 bobo 老师的玩转算法面试 从真题到思维全面提升算法思维 中的第九章,会很仔细的讲解背包问题,对于算法思维有很大的提升,这门课的其他部分也非常非常的优秀。

我也有在我自己维护的题解仓库中对老师的 01 背包解法做了一个js 版的改写

那么 01 背包问题和这个瀑布流算法有什么关系呢?这个思路确实比较难找,但是我们仔细想一下,假设我们有 [1, 2, 3] 这 3 个图片高度的数组,我们怎么通过转化成 01 背包问题呢?

由于我们要凑到的是图片总高度的一半,也就是 (1 + 2 + 3) / 2 = 3,那么我们此时就有了一个 容量为3 的背包,而由于我们装进左列中的图片高度需要低于总高度的一半,待装进背包的物体的总重量和高度是相同的 [1, 2, 3]

那么这个问题也就转化为了,在 容量为3的背包 中,尽可能的从重量为 [1, 2, 3],并且价值也为 [1, 2, 3] 的物品中,尽可能的挑选出总价值最大的物品集合装进背包中。

也就是 总高度为3,在 [1, 2, 3] 这几种高度的图片中,尽可能挑出 总和最大,但是又小于3 的图片集合,装进数组中。

可以分析出 状态转移方程

dp[heights][height] = max(
  // 选择当前图片放入列中
  currentHeight + dp[heights - 1][height - currnetHeight], 
  // 不选择当前图片
  dp[heights - 1][height]
)

注意这里的纵坐标命名为 heights,代表它的意义是「可选择图片的集合」,比如 dp[0] 意味着只考虑第一张图片,dp[1] 则意味着既考虑第一张图片又考虑第二张图片,以此类推。

二维数组结构

我们构建的二维 dp 数组

纵坐标 y 是:当前可以考虑的图片,比如 dp[0] 是只考虑下标为 0 的图片,dp[1] 是考虑下标为 0 的图片,并且考虑下标为 1 的图片,以此类推,取值范围是 0 ~ 图片数组的长度 - 1

横坐标 x 是:用当前考虑的图片集合,去尽可能凑到总高度为 y 时,所能凑成的最大高度 max,以及当前所使用的图片下标集合 indexes,取值范围是 0 ~ 高度的一半

小问题拆解

就以 [1, 4, 5, 4] 这四张图片高度为例,高度的一半是 7,用肉眼可以看出最接近 7 的子数组是[1, 5],我们来看看动态规划是怎么求出这个结果的。

我们先看纵坐标为 0,也就是只考虑图片 1 的情况:

  1. 首先去尝试凑高度 1:我们知道图片 1 的高度正好是 1,所以此时dp[0][0]所填写的值是 { max: 1, indexes: [0] },也就代表用总高度还剩 1,并且只考虑图片 1 的情况下,我们的最优解是选用第一张图片。
  2. 凑高度2 ~ 7:由于当前只有 1 可以选择,所以最优解只能是选择第一张图片,它们都是 { max: 1, indexes: [0] }
高度       1  2  3  4  5  6  7
图片1(h=1) 1  1  1  1  1  1  1

这一层在动态规划中叫做基础状态,它是最小的子问题,它不像后面的纵坐标中要考虑多张图片,而是只考虑单张图片,所以一般来说都会在一层循环中单独把它求解出来。

这里我们还要考虑第一张图片的高度大于我们要求的总高度的情况,这种情况下需要把 max 置为 0,选择的图片项也为空。

let mid = Math.round(sum(heights) / 2)
let dp = []
// 基础状态 只考虑第一个图片的情况
dp[0] = []
for (let cap = 0; cap <= mid; cap++) {
  dp[0][cap] =
    heights[0] > cap
      ? { max: 0, indexes: [] }
      : { max: heights[0], indexes: [0] }
}

有了第一层的基础状态后,我们就可以开始考虑多张图片的情况了,现在来到了纵坐标为 1,也就是考虑图片 1 和考虑图片 2 时求最优解:

高度       1  2  3  4  5  6  7
图片1(h=1) 1  1  1  1  1  1  1
图片2(h=2)

此时问题就变的有些复杂了,在多张图片的情况下,我们可以有两种选择:

  1. 选择当前图片,那么假设当前要凑的总高度为 3,当前图片的高度为 2,剩余的高度就为 1,此时我们可以用剩余的高度去「上一个纵坐标」里寻找「只考虑前面几种图片」的情况下,高度为 1 时的最优解。并且记录 当前图片的高度 + 前几种图片凑剩余高度的最优解max1
  2. 不选择当前图片,那么就直接去「只考虑前面几种图片」的上一个纵坐标里,找到当前高度下的最优解即可,记为 max2
  3. 比较 max1max2,找出更大的那个值,记录为当前状态下的最优解。

有了这个前置知识,来继续分解这个问题,在纵坐标为 1 的情况下,我们手上可以选择的图片有图片 1 和图片 2:

  1. 凑高度 1:由于图片 2 的高度为 2,相当于是容量超了,所以这种情况下不选择图片 2,而是直接选择图片 1,所以 dp[1][0] 可以直接沿用 dp[0][0]的最优解,也就是 { max: 1, indexes: [0] }
  2. 凑高度 2:

    1. 选择图片 2,图片 2 的高度为 4,能够凑成的高度为 4,已经超出了当前要凑的高度 2,所以不能选则图片 2。
    2. 不选择图片 2,在只考虑图片 1 时的最优解数组 dp[0] 中找到高度为 2 时的最优解: dp[0][2],直接沿用下来,也就是 { max: 1, indexes: [0] }
    3. 这种情况下只能不选择图片 2,而沿用只选择图片 1 时的解, { max: 1, indexes: [0] }
  3. 省略凑高度 3 ~ 4 的情况,因为得出的结果和凑高度 2 是一样的。
  4. 凑高度 5:高度为 5 的情况下就比较有意思了:

    1. 选择图片 2,图片 2 的高度为 4,能够凑成的高度为 4,此时剩余高度是 1,再去只考虑图片 1 的最优解数组 dp[0]中找高度为 1 时的最优解dp[0][1],发现结果是 { max: 1, indexes: [0] },这两个高度值 4 和 1 相加后没有超出高度的限制,所以得出最优解:{ max: 5, indexes: [0, 1] }
    2. 不选择图片 2,在图片 1 的最优解数组中找到高度为 5 时的最优解: dp[0][5],直接沿用下来,也就是 { max: 1, indexes: [0] }
    3. 很明显选择图片 2 的情况下,能凑成的高度更大,所以 dp[1][2] 的最优解选择 { max: 5, indexes: [0, 1] }

仔细理解一下,相信你可以看出动态规划的过程,从最小的子问题 只考虑图片1出发,先求出最优解,然后再用子问题的最优解去推更大的问题 考虑图片1、2考虑图片1、2、3的最优解。

画一下[1,4,5,4]问题的 dp 状态表吧:

可以看到,和我们刚刚推论的结果一致,在考虑图片 1 和图片 2 的情况下,凑高度为 5,也就是dp[1][5]的位置的最优解就是 5。

最右下角的 dp[3][7] 就是考虑所有图片的情况下,凑高度为 7 时的全局最优解

dp[3][7] 的推理过程是这样的:

  1. 用最后一张高度为 4 的图片,加上前三张图片在高度为 7 - 4 = 3 时的最优解也就是 dp[2][3],得到结果 4 + 1 = 5。
  2. 不用最后一张图片,直接取前三张图片在高度为 7 时的最优解,也就是 dp[2][7],得到结果 6。
  3. 对比这两者的值,得到最优解 6。

至此我们就完成了整个动态规划的过程,得到了考虑所有图片的情况下,最大高度为 7 时的最优解:6,所需的两张图片的下标为 [0, 2],对应高度是 15

给出代码:

// 尽可能选出图片中高度最接近图片总高度一半的元素
let dpHalf = (heights) => {
  let mid = Math.round(sum(heights) / 2)
  let dp = []

  // 基础状态 只考虑第一个图片的情况
  dp[0] = []
  for (let cap = 0; cap <= mid; cap++) {
    dp[0][cap] =
      heights[0] > cap
        ? { max: 0, indexes: [] }
        : { max: heights[0], indexes: [0] }
  }

  for (
    let useHeightIndex = 1;
    useHeightIndex < heights.length;
    useHeightIndex++
  ) {
    if (!dp[useHeightIndex]) {
      dp[useHeightIndex] = []
    }
    for (let cap = 0; cap <= mid; cap++) {
      let usePrevHeightDp = dp[useHeightIndex - 1][cap]
      let usePrevHeightMax = usePrevHeightDp.max
      let currentHeight = heights[useHeightIndex]
      // 这里有个小坑 剩余高度一定要转化为整数 否则去dp数组里取到的就是undefined了
      let useThisHeightRestCap = Math.round(cap - heights[useHeightIndex])
      let useThisHeightPrevDp = dp[useHeightIndex - 1][useThisHeightRestCap]
      let useThisHeightMax = useThisHeightPrevDp
        ? currentHeight + useThisHeightPrevDp.max
        : 0

      // 是否把当前图片纳入选择 如果取当前的图片大于不取当前图片的高度
      if (useThisHeightMax > usePrevHeightMax) {
        dp[useHeightIndex][cap] = {
          max: useThisHeightMax,
          indexes: useThisHeightPrevDp.indexes.concat(useHeightIndex),
        }
      } else {
        dp[useHeightIndex][cap] = {
          max: usePrevHeightMax,
          indexes: usePrevHeightDp.indexes,
        }
      }
    }
  }

  return dp[heights.length - 1][mid]
}

有了一侧的数组以后,我们只需要在数组中找出另一半,即可渲染到屏幕的两列中:

this.leftImgIndexes = dpHalf(imgHeights).indexes
this.rightImgIndexes = omitByIndexes(this.imgs, this.leftImgIndexes)

得出效果:

优化 1

由于纵轴的每一层的最优解都只需要参考上一层节点的最优解,因此可以只保留两行。通过判断除 2 取余来决定“上一行”的位置。此时空间复杂度是 O(n)。

优化 2

由于每次参考值都只需要取上一行和当前位置左边位置的值(因为减去了当前高度后,剩余高度的最优解一定在左边),因此 dp 数组可以只保留一行,把问题转为从右向左求解,并且在求解的过程中不断覆盖当前的值,而不会影响下一次求解。此时空间复杂度是 O(n),但是其实占用的空间进一步缩小了。

并且在这种情况下对于时间复杂度也可以做优化,由于优化后,求当前高度的最优解是倒序遍历的,那么当发现求最优解的高度小于当前所考虑的那个图片的的高度时,说明本次求解不可能考虑当前图片了,此时左边的高度的最优解一定是「上一行的最优解」。

代码地址

预览地址

完整代码地址

总结

算法思想在前端中的应用还是可以见到不少的,本文只是为了演示动态规划在求解最优解问题时的威力,并不代表这种算法适用于生产环境(实际上性能非常差)。

在实际场景中我们可能一定需要最优解,而只是需要左右两侧的高度不要相差的过大就好,那么这种情况下简单的贪心算法完全足够。

在业务工程中,我们需要结合当前的人力资源,项目周期,代码可维护性,性能等各个方面,去选择最适合业务场景的解法,而不一定要去找到那个最优解。

但是算法对于前端来说还是非常重要的,想要写出 bug free 的代码,在复杂的业务场景下也能游刃有余的想出优化复杂度的方法,学习算法是一个非常棒的途径,这也是工程师必备的素养。

推荐

我维护了一个 LeetCode 的题解仓库,这里会按照标签分类记录我平常刷题时遇到的一些比较经典的问题,并且也会经常更新 bobo 老师的力扣算法课程中提到的各个分类的经典算法,把他 C++ 的解法改写成 JavaScript 解法。欢迎关注,我会持续更新。

参考资料

一文搞懂动态规划

玩转算法面试 从真题到思维全面提升算法思维

❤️ 感谢大家

1.如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

2.关注公众号「前端从进阶到入院」即可加我好友,我拉你进「前端进阶交流群」,大家一起共同交流和进步。

查看原文

赞 25 收藏 14 评论 5

李大雷 收藏了文章 · 6月3日

vue-cli踩坑记录

前段时间遇到了一个vue-cli导致的bug,钻研了半天才解决,于是有空写篇文章记录下来。

一、奇怪的bug出现

有用户反馈在ie11下打不开我们的线上网站,即使开了兼容模式也打不开我们的网站,页面白屏。
image.png

初步判断,由于css样式资源、页面资源都已经加载到位,排除网络环境问题后,让用户打开控制台截图看一下,白屏的原因是由于JS执行报错阻塞了后续的逻辑执行和渲染。

image.png

代码中存在执行错误的逻辑,但是按照打包后的压缩js文件是完全无法确定问题的,如果你有接入sentry或者badjs等错误上报模块,那么你可以根据错误信息,快速定位到源码上的问题。
但是问题又出现了,sentry的引入其实最合法合规最正确的引入方式,应该是最页面资源加载的最前面,有些人会其防止在head标签中优先加载,这样页面后续无论是资源加载出错、HTML解析错误 或者 JS逻辑执行报错,都可以捕获到并且进行上报。
然而当初没有考虑到这一点,而是直接在vue-cli中的main.js中直接import sentry的方式引入,这样意味着打包后的bundle如果首次执行出错的话,sentry都未能完成初始化,导致错误内容无法上报。
根据用户的截图也不能很快的确定问题代码以及报错内容,于是先让有windows电脑的同事用ie11打开网址,查看报错内容,果然,报错了,报的错还不止一个。

image.png

定位对应的行代码:
image.png

image.png
第二个问题主要是由于使用v-model绑定type为radio值的input时,还自定义了属性:checked,导致最后编译出来的代码里出现对重复属性的定义,而正式环境下的bundle包一般都是默认'use strict',所以导致触发严格模式,ie11报错,根据属性名便可以快速定位问题并修改。

最后还是回来讲讲第一个问题,智障IE还不支持class?是的 不支持
image.png
但是 为啥我们的打包产物里会出现莫名其妙的ES6代码?
按理说vue-cli已经帮我们引入了babel-loader针对ES6代码进行了转换,为啥还会出现class呢?
带着这个问题我们继续往下看

二、分析原因、确定问题

根据报错文件chunk-vendors,我们来开一下我们的vue.config.js中针对不同chunk的配置文件:

optimization: {
      splitChunks: {
        cacheGroups: {
          echarts: {
            name: 'chunk-echarts',
            test: /[\\/]node_modules[\\/]echarts[\\/]/,
            chunks: 'all',
            priority: 10,
            reuseExistingChunk: true,
            enforce: true,
          },
          demo: {
            name: 'chunk-demo',
            test: /[\\/]src[\\/]views[\\/]demo[\\/]/,
            chunks: 'all',
            priority: 20,
            reuseExistingChunk: true,
            enforce: true,
          },
          page: {
            name: 'chunk-page',
            test: /[\\/]src[\\/]/,
            chunks: 'all',
            priority: 10,
            reuseExistingChunk: true,
            enforce: true,
          },
          vendors: {
            name: 'chunk-vendors',
            test: /[\\/]node_modules[\\/]/,
            chunks: 'all',
            priority: 5,
            reuseExistingChunk: true,
            enforce: true,
          },
        },
      },
    },

出现es6代码都来自于node_modules下的chunk包,为啥呢?
接下来为了进一步确定我们的es6代码是从哪个第三方库引入的,我们需要暂时关闭掉uglify压缩代码,配置如下:

module.exports = {
  ...
  optimization: {
    minimize: false,
    ...
  }
};  

再进行以上配置,即可以关闭webpack4的默认压缩配置,这时候我们再来我们打包产物中找找,大片的const和let,还有我们要找的罪魁祸首,class:

image.png

对应的第三方库的地址为./node_modules/dom7/dist/dom7.modular.js

接下来我们要继续追查,是哪里引用到了这个包,在package-lock.json里很快锁定到目标:

image.png

swiper是移动端用户轮播的一个第三方库,而这里引用到了dom7模块,
我们翻一下swiper的源码 在它的源码中的swiper.esm.bundle.js打包产物中,的确看到这么一条引入:

import { $, addClass, removeClass, hasClass, toggleClass, attr, removeAttr, data, transform, transition as transition$1, on, off, trigger, transitionEnd as transitionEnd$1, outerWidth, outerHeight, offset, css, each, html, text, is, index, eq, append, prepend, next, nextAll, prev, prevAll, parent, parents, closest, find, children, remove, add, styles } from 'dom7/dist/dom7.modular';

但是为啥我们引入的是esm的产物代码呢?默认webpack不会帮我们引入main字段对应的产物吗?
翻看一下webpack的官方文档,我们可以看到有这么一个配置项:
resolve.mainFields:
当从 npm 包中导入模块时(例如,import * as D3 from "d3"),此选项将决定在 package.json 中使用哪个字段导入模块。根据 webpack 配置中指定的 target 不同,默认值也会有所不同。

image.png

webpack的默认配置下,mainfields字段所指定的是优先以module为入口,这么设计的原因是为了顺应时代的潮流,让大家使用es6导出的模块,逐步淘汰掉以往的Commonjs模块规范,毕竟import、export能带来更多的好处。
而我们的vue-cli在构建编译 默认target是为node,所以我们的mainFields字段也默认为['module','main']

原因分析:

到这里我们已经明白问题的关键是啥了,由于webpack不会对node_modules中引入的第三方库内的代码进行二次的ES6转码处理,而webpack默认又会引入module字段所指向的打包产物,module产物一般是ES6规范输出,main产物一般是commonjs或UMD规范输出,所以部分ES6代码的残留导致IE11不兼容,最终导致报错。

三、修改配置

竟然确定了问题所在,接下来我们只需要按照vue-cli官方文档所介绍,自行配置一下webpack配置即可:

configureWebpack: {
    resolve: {
      mainFields: ['main', 'module'],
    },

vue-cli中默认引入了一些loader和plugin,所以内部已经有一份webpack的配置了,vue-cli通过暴露configureWebpack参数的方式,允许使用者对webpack进行再配置,如果是以对象形式传入,会与内部的webpack配置对象进行merge操作。

修改完配置后,最后检查一下我们的打包产物,的确已经没有了const、let、class等这类es6语法,大功告成,bug解决。

当然,如果你的网站需要兼容大多数浏览器和不同场景的话,你还需要为你的代码引入polyfill,毕竟ES6转ES5只是针对部分语法,然而ES6所新增的部分API是不会进行转换的,这时候只能通过引入前置polyfill的方式来达到兼容。

谢谢观看~

查看原文

李大雷 发布了文章 · 6月2日

解决electron打包慢,卡

前言

这两天搞了一个electron项目,代码2小时,打包1天。因此写个文章给打包困难的朋友。

1.安装electron-builder

npm install electron-builder --save-dev

2.更改npm的源和版本设置

这个设置可以在你执行安装依赖之前就设置好

//你可以使用终端输入命令
npm set ELECTRON\_MIRROR\=https://npm.taobao.org/mirrors/electron/

npm set ELECTRON\_CUSTOM\_DIR\=9.0.0

或者直接在C:\user\xxx路径下 搜索.npmrc然后打开文件进行修改
image.png
image.png

注意:这个版本号需要和你package.json中的版本号相同

3.增加package.json里的设置

"build": {
    "appId": "com.xxx.app",
    "mac": {
      "target": ["dmg","zip"]
    },
    "win": {
      "target": ["nsis","zip"]
    }
},
"scripts": {
    "dist": "electron-builder --win --x64"
},

这里默认是打windows 64位的包.
如果打其他平台的包,只需要更改dist对应的命令,大概怎么配置请百度builder的配置

4.执行打包

npm run dist

打包需要依赖于三个包,分别是

  1. electron-v版本-打包的平台.zip的包
  2. winCodeSign
  3. nsis

其中第一个因为我们已经配置了下载的源,所以按理说是百分比成功的,大概1分钟下载好,第二第三个是从GitHub拉的,国内网络的话很大可能会卡住。如果卡住的话我们可以手动下载相关的包,放置到指定目录即可。
image.png

  • 第一个包:从这里我们可以看到下载地址,如果下载失败的话,我们可以可以ctrl+click来点击这个链接,看看能否正常下载,如果不可以,那么我们可能要更改一下我们第2步的npm设置,因为第一步没有下载失败的情况,因此不作赘述
  • 第二个包:winCodeSign 如果无法下载,我们使用ctrl+click手动下载好安装包以后,解压到如下目录即可: C:\用户\xx\AppData\Local\electron-builder\Cache\winCodeSign
    image.png
  • 第三个包:nsis 如果无法下载,那我们从如下网站下载相应版本的包 https://github.com/electron-userland/electron-builder-binaries/releases
    image.png
    image.png
    下载好以后,重命名这两个文件夹(即加上版本的后缀),然后移动到对应目录
    image.png
    image.png

最后再跑一遍npm run dist命令,如果遇到报错!请一定要确认你的路径中没有中文,我就是吃了一个大亏!

5.过度疲劳之后

成果如下,点击exe即可执行
image.png

查看原文

赞 7 收藏 6 评论 4

李大雷 发布了文章 · 6月2日

【electron】网易云转码客户端

前言

最近有事需要用到一些伴奏,然后发现很多播放器下载歌要钱,可以听但是不能下载,作为一个穷B,只能想想办法怎么嫖它一顿~

逛sf的时候刚好看到一篇文章
如何从缓存白嫖网易云音乐
这不巧了吗?作者实现了在线转码,要下载,下载还要流量啊?我这几千首转码完还要下载等很久啊。
不如写一个客户端本地转它一炮!
然后花了一天时间看electron的入门教程,发现非常简单,如果你会一点node.js的话,非常bong!

解码原理

可以看前言里的链接文章,里面已经讲得非常详细了。不作赘述

electron

为什么会用electron,因为我要写一个桌面应用来直接操作我的文件,这样就不用免了在线转码上传下载的网络消耗了~

使用electron就相当于我们前端也可以操作浏览器以外的东西了(借助node的能力),感觉非常好!而且写页面就是写html,css,可以实现非常棒的视觉效果,而且这对于前端来说也太吉尔简单了!

一个大坑

electron的打包确实是非常非常吉尔难受,其实命令就几行,但是考虑到墙里墙外的问题,我打了好几个小时才搞好。

源码

为什么我之前为什么没用github,因为懒。现在才开始用,希望各位觉得有点jb用的老爷可以给个
网易云音乐转码器

查看原文

赞 12 收藏 4 评论 6

李大雷 赞了文章 · 5月31日

vue-cli踩坑记录

前段时间遇到了一个vue-cli导致的bug,钻研了半天才解决,于是有空写篇文章记录下来。

一、奇怪的bug出现

有用户反馈在ie11下打不开我们的线上网站,即使开了兼容模式也打不开我们的网站,页面白屏。
image.png

初步判断,由于css样式资源、页面资源都已经加载到位,排除网络环境问题后,让用户打开控制台截图看一下,白屏的原因是由于JS执行报错阻塞了后续的逻辑执行和渲染。

image.png

代码中存在执行错误的逻辑,但是按照打包后的压缩js文件是完全无法确定问题的,如果你有接入sentry或者badjs等错误上报模块,那么你可以根据错误信息,快速定位到源码上的问题。
但是问题又出现了,sentry的引入其实最合法合规最正确的引入方式,应该是最页面资源加载的最前面,有些人会其防止在head标签中优先加载,这样页面后续无论是资源加载出错、HTML解析错误 或者 JS逻辑执行报错,都可以捕获到并且进行上报。
然而当初没有考虑到这一点,而是直接在vue-cli中的main.js中直接import sentry的方式引入,这样意味着打包后的bundle如果首次执行出错的话,sentry都未能完成初始化,导致错误内容无法上报。
根据用户的截图也不能很快的确定问题代码以及报错内容,于是先让有windows电脑的同事用ie11打开网址,查看报错内容,果然,报错了,报的错还不止一个。

image.png

定位对应的行代码:
image.png

image.png
第二个问题主要是由于使用v-model绑定type为radio值的input时,还自定义了属性:checked,导致最后编译出来的代码里出现对重复属性的定义,而正式环境下的bundle包一般都是默认'use strict',所以导致触发严格模式,ie11报错,根据属性名便可以快速定位问题并修改。

最后还是回来讲讲第一个问题,智障IE还不支持class?是的 不支持
image.png
但是 为啥我们的打包产物里会出现莫名其妙的ES6代码?
按理说vue-cli已经帮我们引入了babel-loader针对ES6代码进行了转换,为啥还会出现class呢?
带着这个问题我们继续往下看

二、分析原因、确定问题

根据报错文件chunk-vendors,我们来开一下我们的vue.config.js中针对不同chunk的配置文件:

optimization: {
      splitChunks: {
        cacheGroups: {
          echarts: {
            name: 'chunk-echarts',
            test: /[\\/]node_modules[\\/]echarts[\\/]/,
            chunks: 'all',
            priority: 10,
            reuseExistingChunk: true,
            enforce: true,
          },
          demo: {
            name: 'chunk-demo',
            test: /[\\/]src[\\/]views[\\/]demo[\\/]/,
            chunks: 'all',
            priority: 20,
            reuseExistingChunk: true,
            enforce: true,
          },
          page: {
            name: 'chunk-page',
            test: /[\\/]src[\\/]/,
            chunks: 'all',
            priority: 10,
            reuseExistingChunk: true,
            enforce: true,
          },
          vendors: {
            name: 'chunk-vendors',
            test: /[\\/]node_modules[\\/]/,
            chunks: 'all',
            priority: 5,
            reuseExistingChunk: true,
            enforce: true,
          },
        },
      },
    },

出现es6代码都来自于node_modules下的chunk包,为啥呢?
接下来为了进一步确定我们的es6代码是从哪个第三方库引入的,我们需要暂时关闭掉uglify压缩代码,配置如下:

module.exports = {
  ...
  optimization: {
    minimize: false,
    ...
  }
};  

再进行以上配置,即可以关闭webpack4的默认压缩配置,这时候我们再来我们打包产物中找找,大片的const和let,还有我们要找的罪魁祸首,class:

image.png

对应的第三方库的地址为./node_modules/dom7/dist/dom7.modular.js

接下来我们要继续追查,是哪里引用到了这个包,在package-lock.json里很快锁定到目标:

image.png

swiper是移动端用户轮播的一个第三方库,而这里引用到了dom7模块,
我们翻一下swiper的源码 在它的源码中的swiper.esm.bundle.js打包产物中,的确看到这么一条引入:

import { $, addClass, removeClass, hasClass, toggleClass, attr, removeAttr, data, transform, transition as transition$1, on, off, trigger, transitionEnd as transitionEnd$1, outerWidth, outerHeight, offset, css, each, html, text, is, index, eq, append, prepend, next, nextAll, prev, prevAll, parent, parents, closest, find, children, remove, add, styles } from 'dom7/dist/dom7.modular';

但是为啥我们引入的是esm的产物代码呢?默认webpack不会帮我们引入main字段对应的产物吗?
翻看一下webpack的官方文档,我们可以看到有这么一个配置项:
resolve.mainFields:
当从 npm 包中导入模块时(例如,import * as D3 from "d3"),此选项将决定在 package.json 中使用哪个字段导入模块。根据 webpack 配置中指定的 target 不同,默认值也会有所不同。

image.png

webpack的默认配置下,mainfields字段所指定的是优先以module为入口,这么设计的原因是为了顺应时代的潮流,让大家使用es6导出的模块,逐步淘汰掉以往的Commonjs模块规范,毕竟import、export能带来更多的好处。
而我们的vue-cli在构建编译 默认target是为node,所以我们的mainFields字段也默认为['module','main']

原因分析:

到这里我们已经明白问题的关键是啥了,由于webpack不会对node_modules中引入的第三方库内的代码进行二次的ES6转码处理,而webpack默认又会引入module字段所指向的打包产物,module产物一般是ES6规范输出,main产物一般是commonjs或UMD规范输出,所以部分ES6代码的残留导致IE11不兼容,最终导致报错。

三、修改配置

竟然确定了问题所在,接下来我们只需要按照vue-cli官方文档所介绍,自行配置一下webpack配置即可:

configureWebpack: {
    resolve: {
      mainFields: ['main', 'module'],
    },

vue-cli中默认引入了一些loader和plugin,所以内部已经有一份webpack的配置了,vue-cli通过暴露configureWebpack参数的方式,允许使用者对webpack进行再配置,如果是以对象形式传入,会与内部的webpack配置对象进行merge操作。

修改完配置后,最后检查一下我们的打包产物,的确已经没有了const、let、class等这类es6语法,大功告成,bug解决。

当然,如果你的网站需要兼容大多数浏览器和不同场景的话,你还需要为你的代码引入polyfill,毕竟ES6转ES5只是针对部分语法,然而ES6所新增的部分API是不会进行转换的,这时候只能通过引入前置polyfill的方式来达到兼容。

谢谢观看~

查看原文

赞 21 收藏 14 评论 2

认证与成就

  • 获得 1166 次点赞
  • 获得 6 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • 网易云转码器

    网易云转码器,可将网易云音乐收听的缓存文件转换成mp3文件

  • 个人博客

    个人博客1.0版本,使用react搭建

注册于 2018-08-06
个人主页被 2k 人浏览