注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

前端面试知识点(一)

基础知识 基础知识主要包含以下几个方面: 基础:计算机原理、编译原理、数据结构、算法、设计模式、编程范式等基本知识了解 语法:JavaScript、ECMAScript、CSS、TypeScript、HTML、Node.js 等语法的了解和使用 框架:Rea...
继续阅读 »

基础知识


基础知识主要包含以下几个方面:



  • 基础:计算机原理、编译原理、数据结构、算法、设计模式、编程范式等基本知识了解

  • 语法:JavaScript、ECMAScript、CSS、TypeScript、HTML、Node.js 等语法的了解和使用

  • 框架:React、Vue、Egg、Koa、Express、Webpack 等原理的了解和使用

  • 工程:编译工具、格式工具、Git、NPM、单元测试、Nginx、PM2、CI / CD 了解和使用

  • 网络:HTTP、TCP、UDP、WebSocket、Cookie、Session、跨域、缓存、协议的了解

  • 性能:编译性能、监控、白屏检测、SEO、Service Worker 等了解

  • 插件:Chrome 、Vue CLI 、Webpack 等插件设计思路的理解

  • 系统:Mac、Windows、Linux 系统配置的实践

  • 后端:Redis 缓存、数据库、Graphql、SSR、模板引擎等了解和使用


基础


1、列举你所了解的计算机存储设备类型?


现代计算机以存储器为中心,主要由 CPU、I / O 设备以及主存储器三大部分组成。各个部分之间通过总线进行连接通信,具体如下图所示:
image.png
上图是一种多总线结构的示意图,CPU、主存以及 I / O 设备之间的所有数据都是通过总线进行并行传输,使用局部总线是为了提高 CPU 的吞吐量(CPU 不需要直接跟 I / O 设备通信),而使用高速总线(更贴近 CPU)和 DMA 总线则是为了提升高速 I / O 设备(外设存储器、局域网以及多媒体等)的执行效率。


主存包括随机存储器 RAM 和只读存储器 ROM,其中 ROM 又可以分为 MROM(一次性)、PROM、EPROM、EEPROM 。ROM 中存储的程序(例如启动程序、固化程序)和数据(例如常量数据)在断电后不会丢失。RAM 主要分为静态 RAM(SRAM) 和动态 RAM(DRAM) 两种类型(DRAM 种类很多,包括 SDRAM、RDRAM、CDRAM 等),断电后数据会丢失,主要用于存储临时程序或者临时变量数据。 DRAM 一般访问速度相对较慢。由于现代 CPU 读取速度要求相对较高,因此在 CPU 内核中都会设计 L1、L2 以及 L3 级别的多级高速缓存,这些缓存基本是由 SRAM 构成,一般访问速度较快。


2、一般代码存储在计算机的哪个设备中?代码在 CPU 中是如何运行的?


高级程序设计语言不能直接被计算机理解并执行,需要通过翻译程序将其转换成特定处理器上可执行的指令,计算机 CPU 的简单工作原理如下所示:
image.png
CPU 主要由控制单元、运算单元和存储单元组成(注意忽略了中断系统),各自的作用如下:



  • 控制单元:在节拍脉冲的作用下,将程序计数器(Program Counter,PC)指向的主存或者多级高速缓存中的指令地址送到地址总线,接着获取指令地址所对应的指令并放入指令寄存器 (Instruction Register,IR)中,然后通过指令译码器(Instruction Decoder,ID)分析指令需要进行的操作,最后通过操作控制器(Operation Controller,OC)向其他设备发出微操作控制信号。

  • 运算单元:如果控制单元发出的控制信号存在算术运算(加、减、乘、除、增 1、减 1、取反等)或者逻辑运算(与、或、非、异或),那么需要通过运算单元获取存储单元的计算数据进行处理。

  • 存储单元:包括片内缓存和寄存器组,是 CPU 中临时数据的存储地方。CPU 直接访问主存数据大概需要花费数百个机器周期,而访问寄存器或者片内缓存只需要若干个或者几十个机器周期,因此会使用内部寄存器或缓存来存储和获取临时数据(即将被运算或者运算之后的数据),从而提高 CPU 的运行效率。


除此之外,计算机系统执行程序指令时需要花费时间,其中取出一条指令并执行这条指令的时间叫指令周期。指令周期可以分为若干个阶段(取指周期、间址周期、执行周期和中断周期),每个阶段主要完成一项基本操作,完成基本操作的时间叫机器周期。机器周期是时钟周期的分频,例如最经典的 8051 单片机的机器周期为 12 个时钟周期。时钟周期是 CPU 工作的基本时间单位,也可以称为节拍脉冲或 T 周期(CPU 主频的倒数) 。假设 CPU 的主频是 1 GHz(1 Hz 表示每秒运行 1 次),那么表示时钟周期为 1 / 109 s。理论上 CPU 的主频越高,程序指令执行的速度越快。


3、什么是指令和指令集?


上图右侧主存中的指令是 CPU 可以支持的处理命令,一般包含算术指令(加和减)、逻辑指令(与、或和非)、数据指令(移动、输入、删除、加载和存储)、流程控制指令以及程序结束指令等,由于 CPU 只能识别二进制码,因此指令是由二进制码组成。除此之外,指令的集合称为指令集(例如汇编语言就是指令集的一种表现形式),常见的指令集有精简指令集(ARM)和复杂指令集(Inter X86)。一般指令集决定了 CPU 处理器的硬件架构,规定了处理器的相应操作。


4、复杂指令集和精简指令集有什么区别?


5、JavaScript 是如何运行的?解释型语言和编译型语言的差异是什么?


早期的计算机只有机器语言时,程序设计必须用二进制数(0 和 1)来编写程序,并且要求程序员对计算机硬件和指令集非常了解,编程的难度较大,操作极易出错。为了解决机器语言的编程问题,慢慢开始出现了符号式的汇编语言(采用 ADD、SUB、MUL、DIV 等符号代表加减乘除)。为了使得计算机可以识别汇编语言,需要将汇编语言翻译成机器能够识别的机器语言(处理器的指令集):
image.png
由于每一种机器的指令系统不同,需要不同的汇编语言程序与之匹配,因此程序员往往需要针对不同的机器了解其硬件结构和指令系统。为了可以抹平不同机器的指令系统,使得程序员可以更加关注程序设计本身,先后出现了各种面向问题的高级程序设计语言,例如 BASIC 和 C,具体过程如下图所示:
image.png
高级程序语言会先翻译成汇编语言或者其他中间语言,然后再根据不同的机器翻译成机器语言进行执行。除此之外,汇编语言虚拟机和机器语言机器之间还存在一层操作系统虚拟机,主要用于控制和管理操作系统的全部硬件和软件资源(随着超大规模集成电路技术的不断发展,一些操作系统的软件功能逐步由硬件来替换,例如目前的操作系统已经实现了部分程序的固化,简称固件,将程序永久性的存储在 ROM 中)。机器语言机器还可以继续分解成微程序机器,将每一条机器指令翻译成一组微指令(微程序)进行执行。


上述虚拟机所提供的语言转换程序被称为编译器,主要作用是将某种语言编写的源程序转换成一个等价的机器语言程序,编译器的作用如下图所示:
image.png
例如 C 语言,可以先通过 gcc 编译器生成 Linux 和 Windows 下的目标 .o 和 .obj 文件(object 文件,即目标文件),然后将目标文件与底层系统库文件、应用程序库文件以及启动文件链接成可执行文件在目标机器上执行。



温馨提示:感兴趣的同学可以了解一下 ARM 芯片的程序运行原理,包括使用 IDE 进行程序的编译(IDE 内置编译器,主流编译器包含 ARMCC、IAR 以及 GCC FOR ARM 等,其中一些编译器仅仅随着 IDE 进行捆绑发布,不提供独立使用的能力,而一些编译器则随着 IDE 进行发布的同时,还提供命令行接口的独立使用方式)、通过串口进行程序下载(下载到芯片的代码区初始启动地址映射的存储空间地址)、启动的存储空间地址映射(包括系统存储器、闪存 FLASH、内置 SRAM 等)、芯片的程序启动模式引脚 BOOT 的设置(例如调试代码时常常选择内置 SRAM、真正程序运行的时候选择闪存 FLASH)等。



如果某种高级语言或者应用语言(例如用于人工智能的计算机设计语言)转换的目标语言不是特定计算机的汇编语言,而是面向另一种高级程序语言(很多研究性的编译器将 C 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。


除此之外,有些程序设计语言将编译的过程和最终转换成目标程序进行执行的过程混合在一起,这种语言转换程序通常被称为解释器,主要作用是将某种语言编写的源程序作为输入,将该源程序执行的结果作为输出,解释器的作用如下图所示:


image.png


解释器和编译器有很多相似之处,都需要对源程序进行分析,并转换成目标机器可识别的机器语言进行执行。只是解释器是在转换源程序的同时立马执行对应的机器语言(转换和执行的过程不分离),而编译器得先把源程序全部转换成机器语言并产生目标文件,然后将目标文件写入相应的程序存储器进行执行(转换和执行的过程分离)。例如 Perl、Scheme、APL 使用解释器进行转换, C、C++ 则使用编译器进行转换,而 Java 和 JavaScript 的转换既包含了编译过程,也包含了解释过程。


6、简单描述一下 Babel 的编译过程?


7、JavaScript 中的数组和函数在内存中是如何存储的?


JavaScript 中的数组存储大致需要分为两种情况:



  • 同种类型数据的数组分配连续的内存空间

  • 存在非同种类型数据的数组使用哈希映射分配内存空间



温馨提示:可以想象一下连续的内存空间只需要根据索引(指针)直接计算存储位置即可。如果是哈希映射那么首先需要计算索引值,然后如果索引值有冲突的场景下还需要进行二次查找(需要知道哈希的存储方式)。



8、浏览器和 Node.js 中的事件循环机制有什么区别?



阅读链接:面试分享:两年工作经验成功面试阿里P6总结 - 了解 Event Loop 吗?



9、ES6 Modules 相对于 CommonJS 的优势是什么?


10、高级程序设计语言是如何编译成机器语言的?


11、编译器一般由哪几个阶段组成?数据类型检查一般在什么阶段进行?


12、编译过程中虚拟机的作用是什么?


13、什么是中间代码(IR),它的作用是什么?


14、什么是交叉编译?


编译器的设计是一个非常庞大和复杂的软件系统设计,在真正设计的时候需要解决两个相对重要的问题:



  • 如何分析不同高级程序语言设计的源程序

  • 如何将源程序的功能等价映射到不同指令系统的目标机器


为了解决上述两项问题,编译器的设计最终被分解成前端(注意这里所说的不是 Web 前端)和后端两个编译阶段,前端用于解决第一个问题,而后端用于解决第二个问题,具体如下图所示:
image.png
上图中的中间表示(Intermediate Representation,IR)是程序结构的一种表现方式,它会比 AST(后续讲解)更加接近汇编语言或者指令集,同时也会保留源程序中的一些高级信息,除此之外 ,它的种类很多,包括三地址码(Three Address Code, TAC)静态单赋值形式(Static Single Assignment Form, SSA)以及基于栈的 IR 等,具体作用包括:



  • 靠近前端部分主要适配不同的源程序,靠近后端部分主要适配不同的指令集,更易于编译器的错误调试,容易识别是 IR 之前还是之后出问题

  • 如下左图所示,如果没有 IR,那么源程序到指令集之间需要进行一一适配,而有了中间表示,则可以使得编译器的职责更加分离,源程序的编译更多关注如何转换成 IR,而不是去适配不同的指令集

  • IR 本身可以做到多趟迭代从而优化源程序,在每一趟迭代的过程中可以研究代码并记录优化的细节,方便后续的迭代查找并利用这些优化信息,最终可以高效输出更优的目标程序


image.png
由于 IR 可以进行多趟迭代进行程序优化,因此在编译器中可插入一个新的优化阶段,如下图所示:
image.png
优化器可以对 IR 处理一遍或者多遍,从而生成更快执行速度(例如找到循环中不变的计算并对其进行优化从而减少运算次数)或者更小体积的目标程序,也可能用于产生更少异常或者更低功耗的目标程序。除此之外,前端和后端内部还可以细分为多个处理步骤,具体如下图所示:
image.png
优化器中的每一遍优化处理都可以使用一个或多个优化技术来改进代码,每一趟处理最终都是读写 IR 的操作,这样不仅仅可以使得优化可以更加高效,同时也可以降低优化的复杂度,还提高了优化的灵活性,可以使得编译器配置不同的优化选项,达到组合优化的效果。


15、发布 / 订阅模式和观察者模式的区别是什么?



阅读链接:基于Vue实现一个简易MVVM - 观察者模式和发布/订阅模式



16、装饰器模式一般会在什么场合使用?


17、谈谈你对大型项目的代码解耦设计理解?什么是 Ioc?一般 DI 采用什么设计模式实现?


18、列举你所了解的编程范式?


编程范式(Programming paradigm)是指计算机编程的基本风格或者典型模式,可以简单理解为编程学科中实践出来的具有哲学和理论依据的一些经典原型。常见的编程范式有:



  • 面向过程(Process Oriented Programming,POP)

  • 面向对象(Object Oriented Programming,OOP)

  • 面向接口(Interface Oriented Programming, IOP)

  • 面向切面(Aspect Oriented Programming,AOP)

  • 函数式(Funtional Programming,FP)

  • 响应式(Reactive Programming,RP)

  • 函数响应式(Functional Reactive Programming,FRP)



阅读链接::如果你对于编程范式的定义相对模糊,可以继续阅读 What is the precise definition of programming paradigm? 了解更多。



不同的语言可以支持多种不同的编程范式,例如 C 语言支持 POP 范式,C++ 和 Java 语言支持 OOP 范式,Swift 语言则可以支持 FP 范式,而 Web 前端中的 JavaScript 可以支持上述列出的所有编程范式。


19、什么是面向切面(AOP)的编程?


20、什么是函数式编程?


顾名思义,函数式编程是使用函数来进行高效处理数据或数据流的一种编程方式。在数学中,函数的三要素是定义域、值域和**对应关系。假设 A、B 是非空数集,对于集合 A 中的任意一个数 x,在集合 B 中都有唯一确定的数 f(x) 和它对应,那么可以将 f 称为从 A 到 B 的一个函数,记作:y = f(x)。在函数式编程中函数的概念和数学函数的概念类似,主要是描述形参 x 和返回值 y 之间的对应关系,**如下图所示:




温馨提示:图片来自于简明 JavaScript 函数式编程——入门篇



在实际的编程中,可以将各种明确对应关系的函数进行传递、组合从而达到处理数据的最终目的。在此过程中,我们的关注点不在于如何去实现**对应关系,**而在于如何将各种已有的对应关系进行高效联动,从而可快速进行数据转换,达到最终的数据处理目的,提供开发效率。


简单示例


尽管你对函数式编程的概念有所了解,但是你仍然不知道函数式编程到底有什么特点。这里我们仍然拿 OOP 编程范式来举例,假设希望通过 OOP 编程来解决数学的加减乘除问题:


class MathObject {
constructor(private value: number) {}
public add(num: number): MathObject {
this.value += num;
return this;
}
public multiply(num: number): MathObject {
this.value *= num;
return this;
}
public getValue(): number {
return this.value;
}
}

const a = new MathObject(1);
a.add(1).multiply(2).add(a.multiply(2).getValue());

我们希望通过上述程序来解决 (1 + 2) * 2 + 1 * 2 的问题,但实际上计算出来的结果是 24,因为在代码内部有一个 this.value 的状态值需要跟踪,这会使得结果不符合预期。 接下来我们采用函数式编程的方式:


function add(a: number, b: number): number {
return a + b;
}

function multiply(a: number, b: number): number {
return a * b;
}

const a: number = 1;
const b: number = 2;

add(multiply(add(a, b), b), multiply(a, b));

以上程序计算的结果是 8,完全符合预期。我们知道了 addmultiply 两个函数的实际对应关系,通过将对应关系进行有效的组合和传递,达到了最终的计算结果。除此之外,这两个函数还可以根据数学定律得出更优雅的组合方式:


add(multiply(add(a, b), b), multiply(a, b));

// 根据数学定律分配律:a * b + a * c = a * (b + c),得出:
// (a + b) * b + a * b = (2a + b) * b

// 简化上述函数的组合方式
multiply(add(add(a, a), b), b);


我们完全不需要追踪类似于 OOP 编程范式中可能存在的内部状态数据,事实上对于数学定律中的结合律、交换律、同一律以及分配律,上述的函数式编程代码足可以胜任。


原则


通过上述简单的例子可以发现,要实现高可复用的函数**(对应关系)**,一定要遵循某些特定的原则,否则在使用的时候可能无法进行高效的传递和组合,例如



  • 高内聚低耦合

  • 最小意外原则

  • 单一职责原则

  • ...


如果你之前经常进行无原则性的代码设计,那么在设计过程中可能会出现各种出乎意料的问题(这是为什么新手老是出现一些稀奇古怪问题的主要原因)。函数式编程可以有效的通过一些原则性的约束使你设计出更加健壮和优雅的代码,并且在不断的实践过程中进行经验式叠加,从而提高开发效率。


特点


虽然我们在使用函数的过程中更多的不再关注函数如何实现(对应关系),但是真正在使用和设计函数的时候需要注意以下一些特点:



  • 声明式(Declarative Programming)

  • 一等公民(First Class Function)

  • 纯函数(Pure Function)

  • 无状态和数据不可变(Statelessness and Immutable Data)

  • ...


声明式


我们以前设计的代码通常是命令式编程方式,这种编程方式往往注重具体的实现的过程(对应关系),而函数式编程则采用声明式的编程方式,往往注重如何去组合已有的**对应关系。**简单举个例子:


// 命令式
const array = [0.8, 1.7, 2.5, 3.4];
const filterArray = [];

for (let i = 0; i < array.length; i++) {
const integer = Math.floor(array[i]);
if (integer < 2) {
continue;
}
filterArray.push(integer);
}

// 声明式
// map 和 filter 不会修改原有数组,而是产生新的数组返回
[0.8, 1.7, 2.5, 3.4].map((item) => Math.floor(item)).filter((item) => item > 1);

命令式代码一步一步的告诉计算机需要执行哪些语句,需要关心变量的实例化情况、循环的具体过程以及跟踪变量状态的变化过程。声明式代码更多的不再关心代码的具体执行过程,而是采用表达式的组合变换去处理问题,不再强调怎么做,而是指明**做什么。**声明式编程方式可以将我们设计代码的关注点彻底从过程式解放出来,从而提高开发效率。


一等公民


在 JavaScript 中,函数的使用非常灵活,例如可以对函数进行以下操作:


interface IHello {
(name: string): string;
key?: string;
arr?: number[];
fn?(name: string): string;
}

// 函数声明提升
console.log(hello instanceof Object); // true

// 函数声明提升
// hello 和其他引用类型的对象一样,都有属性和方法
hello.key = 'key';
hello.arr = [1, 2];
hello.fn = function (name: string) {
return `hello.fn, ${name}`;
};

// 函数声明提升
// 注意函数表达式不能在声明前执行,例如不能在这里使用 helloCopy('world')
hello('world');

// 函数
// 创建新的函数对象,将函数的引用指向变量 hello
// hello 仅仅是变量的名称
function hello(name: string): string {
return `hello, ${name}`;
}

console.log(hello.key); // key
console.log(hello.arr); // [1,2]
console.log(hello.name); // hello

// 函数表达式
const helloCopy: IHello = hello;
helloCopy('world');

function transferHello(name: string, hello: Hello) {
return hello('world');
}

// 把函数对象当作实参传递
transferHello('world', helloCopy);

// 把匿名函数当作实参传递
transferHello('world', function (name: string) {
return `hello, ${name}`;
});


通过以上示例可以看出,函数继承至对象并拥有对象的特性。在 JavaScript 中可以对函数进行参数传递、变量赋值或数组操作等等,因此把函数称为一等公民。函数式编程的核心就是对函数进行组合或传递,JavaScript 中函数这种灵活的特性是满足函数式编程的重要条件。


纯函数


纯函数是是指在相同的参数调用下,函数的返回值唯一不变。这跟数学中函数的映射关系类似,同样的 x 不可能映射多个不同的 y。使用函数式编程会使得函数的调用非常稳定,从而降低 Bug 产生的机率。当然要实现纯函数的这种特性,需要函数不能包含以下一些副作用:



  • 操作 Http 请求

  • 可变数据(包括在函数内部改变输入参数)

  • DOM 操作

  • 打印日志

  • 访问系统状态

  • 操作文件系统

  • 操作数据库

  • ...


从以上常见的一些副作用可以看出,纯函数的实现需要遵循最小意外原则,为了确保函数的稳定唯一的输入和输出,尽量应该避免与函数外部的环境进行任何交互行为,从而防止外部环境对函数内部产生无法预料的影响。纯函数的实现应该自给自足,举几个例子:


// 如果使用 const 声明 min 变量(基本数据类型),则可以保证以下函数的纯粹性
let min: number = 1;

// 非纯函数
// 依赖外部环境变量 min,一旦 min 发生变化则输入和返回不唯一
function isEqual(num: number): boolean {
return num === min;
}

// 纯函数
function isEqual(num: number): boolean {
return num === 1;
}

// 非纯函数
function request<T, S>(url: string, params: T): Promise<S> {
// 会产生请求成功和请求失败两种结果,返回的结果可能不唯一
return $.getJson(url, params);
}

// 纯函数
function request<T, S>(url: string, params: T) : () => Promise<S> {
return function() {
return $.getJson(url, params);
}
}

纯函数的特性使得函数式编程具备以下特性:



  • 可缓存性(Cacheable)

  • 可移植性(Portable)

  • 可测试性(Testable)


可缓存性和可测试性基于纯函数输入输出唯一不变的特性,可移植性则主要基于纯函数不依赖外部环境的特性。这里举一个可缓存的例子:


interface ICache<T> {
[arg: string]: T;
}

interface ISquare<T> {
(x: T): T;
}

// 简单的缓存函数(忽略通用性和健壮性)
function memoize<T>(fn: ISquare<T>): ISquare<T> {
const cache: ICache<T> = {};
return function (x: T) {
const arg: string = JSON.stringify(x);
cache[arg] = cache[arg] || fn.call(fn, x);
return cache[arg];
};
}

// 纯函数
function square(x: number): number {
return x * x;
}

const memoSquare = memoize<number>(square);
memoSquare(4);

// 不会再次调用纯函数 square,而是直接从缓存中获取值
// 由于输入和输出的唯一性,获取缓存结果可靠稳定
// 提升代码的运行效率
memoSquare(4);

无状态和数据不可变


在函数式编程的简单示例中已经可以清晰的感受到函数式编程绝对不能依赖内部状态,而在纯函数中则说明了函数式编程不能依赖外部的环境或状态,因为一旦依赖的状态变化,不能保证函数根据对应关系所计算的返回值因为状态的变化仍然保持不变。


这里单独讲解一下数据不可变,在 JavaScript 中有很多数组操作的方法,举个例子:


const arr = [1, 2, 3];

console.log(arr.slice(0, 2)); // [1, 2]
console.log(arr); // [1, 2, 3]
console.log(arr.slice(0, 2)); // [1, 2]
console.log(arr); // [1, 2, 3]

console.log(arr.splice(0, 1)); // [1]
console.log(arr); // [2, 3]
console.log(arr.splice(0, 1)); // [2]
console.log(arr); // [3]

这里的 slice 方法多次调用都不会改变原有数组,且会产生相同的输出。而 splice 每次调用都在修改原数组,且产生的输出也不相同。 在函数式编程中,这种会改变原有数据的函数已经不再是纯函数,应该尽量避免使用。



阅读链接:如果想要了解更深入的函数式编程知识点,可以额外阅读函数式编程指北



21、响应式编程的使用场景有哪些?


响应式编程是一种基于观察者(发布 / 订阅)模式并且面向异步(Asynchronous)数据流(Data Stream)和变化传播的声明式编程范式。响应式编程主要适用的场景包含:



  • 用户和系统发起的连续事件处理,例如鼠标的点击、键盘的按键或者通信设备发起的信号等

  • 非可靠的网络或者通信处理(例如 HTTP 网络的请求重试)

  • 连续的异步 IO 处理

  • 复杂的继发事务处理(例如一次事件涉及到多个继发的网络请求)

  • 高并发的消息处理(例如 IM 聊天)

  • ...

链接:https://juejin.cn/post/6987549240436195364

收起阅读 »

如何在大型代码仓库中删掉 6w 行废弃的文件和 exports?

起因 很多项目历史悠久,其中很多 文件或是 export 出去的变量 已经不再使用,非常影响维护迭代。 举个例子来说,后端问你:“某某接口统计一下某接口是否还有使用?”你在项目里一搜,好家伙,还有好几处使用呢,结果那些定义或文件是从未被引入的,这就会误导你们去...
继续阅读 »

起因


很多项目历史悠久,其中很多 文件或是 export 出去的变量 已经不再使用,非常影响维护迭代。
举个例子来说,后端问你:“某某接口统计一下某接口是否还有使用?”你在项目里一搜,好家伙,还有好几处使用呢,结果那些定义或文件是从未被引入的,这就会误导你们去继续维护这个文件或接口,影响迭代效率。


先从删除废弃的 exports 讲起,后文会讲删除废弃文件。


删除 exports,有几个难点:




  1. 怎么样稳定的 找出 export 出去,但是其他文件未 import 的变量




  2. 如何确定步骤 1 中变量在 本文件内部没有用到 (作用域分析)?




  3. 如何稳定的 删除这些变量




整体思路


先给出整体的思路,公司内的小伙伴推荐了 pzavolinsky/ts-unused-exports 这个开源库,并且已经在项目中稳定使用了一段时间,这个库可以搞定上述第一步的诉求,也就是找出 export 出去,但是其他文件未 import 的变量。
但下面两步依然很棘手,先给出我的结论:



  1. 如何确定步骤 1 中变量在本文件内部没有用到(作用域分析)?


对分析出的文件调用 ESLint 的 API,no-unused-vars 这个 ESLint rule 天生就可以分析出文件内部某个变量是否使用,但默认情况下它是不支持对 export 出去的变量进行分析的,因为既然你 export 了这个变量,那其实 ESLint 就认为你这个变量会被外部使用。对于这个限制,其实只需要 fork 下来稍微改写即可。



  1. 如何稳定的删除这些变量?


自己编写 rule fixer 删除掉分析出来的无用变量,之后就是格式化,由于 ESLint 删除代码后格式会乱掉,所以手动调用 prettier API 让代码恢复美观即可。


接下来我会对上述每一步详细讲解。


导出导入分析


使用测试下来, pzavolinsky/ts-unused-exports 确实可以靠谱的分析出 未使用的 export 变量 ,但是这种分析 import、export 关系的工具,只是局限于此,不会分析 export 出去的这个变量 在代码内部是否有使用到


文件内部使用分析


第二步的问题比较复杂,这里最终选用 ESLint 配合自己 fork 改写 no-unused-vars 这个 rule ,并且自己提供规则对应的修复方案 fixer 来实现。


为什么是 ESLint?




  1. 社区广泛使用,经过无数项目验证。




  2. 基于 作用域分析 ,准确的找出未使用的变量。




  3. 提供的 AST 符合 estree/estree 的通用标准,易于维护拓展。




  4. ESLint 可以解决 删除之后引入新的无用变量的问题 ,最典型的就是删除了某个函数,这个函数内部的某个函数也可能会变成无效代码。ESLint 会 重复执行 fix 函数,直到不再有新的可修复错误为止。





为什么要 fork 下来改写它?




  1. 官方的 no-unused-vars 默认是不考虑 export 出去的变量的,而经过我对源码的阅读发现,仅仅 修改少量的代码 就可以打破这个限制,让 export 出去的变量也可以被分析,在模块内部是否使用。




  2. 第一步的改写后,很多 export 出去的变量 被其他模块引用 ,但由于在 模块内部未使用 ,也会 被分析为未使用变量 。所以需要给 rule 提供一个 varsPattern 的选项,把分析范围限定在 ts-unused-exports 给出的 导出未使用变量 中,如 varsPattern: '^foo$|^bar$'




  3. 官方的 no-unused-vars 只给出提示,没有提供 自动修复 的方案,需要自己编写,下面详细讲解。




如何删除变量


当我们在 IDE 中编写代码时,有时会发现保存之后一些 ESLint 飘红的部分被自动修复了,但另一部分却没有反应。
这其实是 ESLint 的 rule fixer 的作用。
参考官方文档的 Apply Fixer 章节,每个 ESLint Rule 的编写者都可以决定自己的这条规则 是否可以自动修复,以及如何修复。
修复不是凭空产生的,需要作者自己对相应的 AST 节点做分析、删除等操作,好在 ESLint 提供了一个 fixer 工具包,里面封装了很多好用的节点操作方法,比如 fixer.remove()fixer.replaceText()
官方的 no-unused-vars 由于稳定性等原因未提供代码的自动修复方案,需要自己对这个 rule 写对应的 fixer 。官方给出的解释在 Add fix/suggestions to no-unused-vars rule · Issue #14585 · eslint/eslint


核心改动


把 ESLint Plugin 单独拆分到一个目录中,结构如下:


packages/eslint-plugin-deadvars
├── ast-utils.js
├── eslint-plugin.js
├── eslint-rule-typescript-unused-vars.js
├── eslint-rule-unused-vars.js
├── eslint-rule.js
└── package.json



  • eslint-plugin.js : 插件入口,外部引入后才可以使用 rule




  • eslint-rule-unused-vars.js : ESLint 官方的 eslint/no-unused-vars 代码,主要的核心代码都在里面。




  • eslint-rule-typescript-unused-vars : typescript-eslint/no-unused-vars 内部的代码,继承了 eslint/no-unused-vars ,增加了一些 TypeScript AST 节点的分析。




  • eslint-rule.js :规则入口,引入了 typescript rule ,并且利用 eslint-rule-composer 给这个规则增加了自动修复的逻辑。




ESLint Rule 改动


我们的分析涉及到删除,所以必须有一个严格的限定范围,就是 exports 出去 且被 ts-unused-exports 认定为 外部未使用 的变量。
所以考虑增加一个配置 varsPattern ,把 ts-unused-exports 分析出的未使用变量名传入进去,限定在这个名称范围内。
主要改动逻辑是在 collectUnusedVariables 这个函数中,这个函数的作用是 收集作用域中没有使用到的变量 ,这里把 exports 且不符合变量名范围 的全部跳过不处理。


else if (
config.varsIgnorePattern &&
config.varsIgnorePattern.test(def.name.name)
) {
// skip ignored variables
continue;
+ } else if (
+ isExported(variable) &&
+ config.varsPattern &&
+ !config.varsPattern.test(def.name.name)
+) {
+ // 符合 varsPattern
+ continue;
+ }

这样外部就可以这样使用这样的方式来限定分析范围:


rules: {
'@deadvars/no-unused-vars': [
'error',
{ varsPattern: '^foo$|^bar$' },
]
}

接着删除掉原版中 收集未使用变量时isExported 的判断,把 exports 出去但文件内部未使用 的变量也收集起来。由于上一步已经限定了变量名,所以这里只会收集到 ts-unused-exports 分析出来的变量。


if (
!isUsedVariable(variable) &&
- !isExported(variable) &&
!hasRestSpreadSibling(variable)
) {
unusedVars.push(variable);
}

ESLint Rule Fixer


接下来主要就是增加自动修复,这部分的逻辑在 eslint-rule.js 中,简单来说就是对上一步分析出来的各种未使用变量的 AST 节点进行判断和删除。
贴一下简化的函数处理代码:


module.exports = ruleComposer.mapReports(rule, (problem, context) => {
problem.fix = fixer => {
const { node } = problem;
const { parent } = node;

// 函数节点
switch (parent.type) {
case 'FunctionExpression':
case 'FunctionDeclaration':
case 'ArrowFunctionExpression':
// 调用 fixer 进行删除
return fixer.remove(parent);
...
...
default:
return null;
}
};
return problem;
});

目前会对以下几种节点类型进行删除:




  • FunctionExpression




  • FunctionDeclaration




  • ArrowFunctionExpression




  • ImportSpecifier




  • ImportDefaultSpecifier




  • ImportNamespaceSpecifier




  • VariableDeclarator




  • TSEnumDeclaration




后续新增节点的删除逻辑,只需要维护这个文件即可。


无用文件删除


之前基于 webpack-deadcode-plugin 做了一版无用代码删除,但是在实际使用的过程中,发现一些问题。


首先是 速度太慢 ,这个插件会基于 webpack 编译的结果来分析哪些文件是无用的,每次使用都需要编译一遍项目。


而且前几天加入了 fork-ts-checker-webpack-plugin 进行类型检查之后, 这个删除方案突然失效了 ,检测出来的只有 .less 类型的无用文件,经过和排查后发现是这个插件的锅,它会把 src 目录下的所有 ts 文件 都加入到 webpack 的依赖中,也就是 compilation.fileDependencies (可以尝试开启这个插件,在开发环境试着手动改一个完全未导入的 ts 文件,一样会触发重新编译)


而 deadcode-plugin 就是依赖 compilation.fileDependencies 这个变量来判断哪些文件未被使用,所有 ts 文件都在这个变量中的话,扫描出来的无用文件自然就只有其他类型了。


这个行为应该是插件的官方有意而为之,考虑如下情况:


// 直接导入一个 TS 类型
import { IProps } from "./type.ts";

// use IProps

在使用旧版的 fork-ts-checker-webpack-plugin 时,如果此时改动了 IProps 造成了类型错误,是不会触发 webpack 的编译报错的。


经过排查,目前官方的行为好像是把 tsconfig 中的 include 里的所有 ts 文件加入到依赖中,方便改动触发编译,而我们项目中的 include["src/**/*.ts"] ,所以……


具体讨论可以查看这个 Issue: Files that provide only type dependencies for main entry and unused files are not being checked for


方案


首先尝试在 deadcode 模式中手动删除 fork-ts-checker-webpack-plugin,这样可以扫描出无用依赖,但是上文中那样从文件中只导入类型的情况,还是会被认为是无用的文件而误删。


考虑到现实场景中单独建一个 type.ts 文件书写接口或类型的情况比较多,只好先放弃这个方案。


转而一想, pzavolinsky/ts-unused-exports 这个工具既然都能分析出
所有文件的 导入导出变量的依赖关系 ,那分析出未使用的文件应该也是小意思才对。


经过源码调试,大概梳理出了这个工具的原理:



  1. 通过 TypeScript 内置的 ts.parseJsonConfigFileContent API 扫描出项目内完整的 ts 文件路径。


 {
"path": "src/component/A",
"fullPath": "/Users/admin/works/test/src/component/A.tsx",
{
"path": "src/component/B",
"fullPath": "/Users/admin/works/test/apps/app/src/component/B.tsx",
}
...


  1. 通过 TypeScript 内置的一些 compile API 分析出文件之间的 exports 和 imports 关系。


{
"path": "src/component/A",
"fullPath": "/Users/admin/works/test/src/component/A.tsx",
"imports": {
"styled-components": ["default"],
"react": ["default"],
"src/components/B": ["TestComponentB"]
},
"exports": ["TestComponentA"]
}


  1. 根据上述信息来分析出每个文件中每个变量的使用次数,筛选出未使用的变量并且输出。


到此思路也就有了,把所有文件中的 imports 信息取一个合集,然后从第一步的文件集合中找出未出现在 imports 里的文件即可。


一些值得一提的改造


循环删除文件


在第一次检测出无用文件并删除后,很可能会暴露出一些新的无用文件。
比如以下这样的例子:


[
{
"path": "a",
"imports": "b"
},
{
"path": "b",
"imports": "c"
},
{
"path": "c"
}
]

文件 a 引入了文件 b,文件 b 引入了文件 c。


第一轮扫描的时候,没有任何文件引入 a,所以会把 a 视作无用文件。


由于 a 引入了 b,所以不会把 b 视作无用的文件,同理 c 也不会视作无用文件。


所以 第一轮删除只会删掉 a 文件


只要在每次删除后,把 files 范围缩小,比如第一次删除了 a 以后,files 只留下:


[
{
path: "b",
imports: "c",
},
{
path: "c",
},
];

此时会发现没有文件再引入 b 了,b 也会被加入无用文件的列表,再重复此步骤,即可删除 c 文件。


支持 Monorepo


原项目只考虑到了单个项目和单个 tsconfig 的处理,而如今 monorepo 已经非常流行了,monorepo 中每个项目都有自己的 tsconfig,形成一个自己的 project,而经常有项目 A 里的文件或变量被项目 B 所依赖使用的情况。


而如果单独扫描单个项目内的文件,就会把很多被子项目使用的文件误删掉。


这里的思路也很简单:




  1. 增加 --deps 参数,允许传入多个子项目的 tsconfig 路径。




  2. 过滤子项目扫描出的 imports 部分,找出从别名为 @main的主项目中引入的依赖(比如 import { Button } from '@main/components'




  3. 把这部分 imports 合并到主项目的依赖集合中,共同进行接下来的扫描步骤。




支持自定义文件扫描


TypeScript 提供的 API,默认只会扫描 .ts, .tsx 后缀的文件,在开启 allowJS 选项后也会扫描 .js, .jsx 后缀的文件。
而项目中很多的 .less, .svg 的文件也都未被使用,但它们都被忽略掉了。


这里我断点跟进 ts.parseJsonConfigFileContent 函数内部,发现有一些比较隐蔽的参数和逻辑,用比较 hack 的方式支持了自定义后缀。


当然,这里还涉及到了一些比较麻烦的改造,比如这个库原本是没有考虑 index.ts, index.less 同时存在这种情况的,通过源码的一些改造最终绕过了这个限制。


目前默认支持了 .less, .sass, .scss 这些类型文件的扫描 ,只要你确保该后缀的引入都是通过 import 语法,那么就可以通过增加的 extraFileExtensions 配置来增加自定义后缀。


import * as ts from "typescript";

const result = ts.parseJsonConfigFileContent(
parseJsonResult.config,
ts.sys,
basePath,
undefined,
undefined,
undefined,
extraFileExtensions?.map((extension) => ({
extension,
isMixedContent: false,
// hack ways to scan all files
scriptKind: ts.ScriptKind.Deferred,
}))
);

其他方案:ts-prune


ts-prune 是完全基于 TypeScript 服务实现的一个 dead exports 检测方案。


背景


TypeScript 服务提供了一个实用的 API: findAllReferences ,我们平时在 VSCode 里右键点击一个变量,选择 “Find All References” 时,就会调用这个底层 API 找出所有的引用。


ts-morph 这个库封装了包括 findAllReferences 在内的一些底层 API,提供更加简洁易用的调用方式。


ts-prune 就是基于 ts-morph 封装而成。


一段最简化的基于 ts-morph 的检测 dead exports 的代码如下:


// this could be improved... (ex. ignore interfaces/type aliases that describe a parameter type in the same file)
import { Project, TypeGuards, Node } from "ts-morph";

const project = new Project({ tsConfigFilePath: "tsconfig.json" });

for (const file of project.getSourceFiles()) {
file.forEachChild((child) => {
if (TypeGuards.isVariableStatement(child)) {
if (isExported(child)) child.getDeclarations().forEach(checkNode);
} else if (isExported(child)) checkNode(child);
});
}

function isExported(node: Node) {
return TypeGuards.isExportableNode(node) && node.isExported();
}

function checkNode(node: Node) {
if (!TypeGuards.isReferenceFindableNode(node)) return;

const file = node.getSourceFile();
if (
node.findReferencesAsNodes().filter((n) => n.getSourceFile() !== file)
.length === 0
)
console.log(
`[${file.getFilePath()}:${node.getStartLineNumber()}: ${
TypeGuards.hasName(node) ? node.getName() : node.getText()
}`
);
}

优点




  1. TS 的服务被各种 IDE 集成,经过无数大型项目检测,可靠性不用多说。




  2. 不需要像 ESLint 方案那样,额外检测变量在文件内是否使用, findAllReferences 的检测范围包括文件内部,开箱即用。




缺点




  1. 速度慢 ,TSProgram 的初始化,以及 findAllReferences 的调用,在大型项目中速度还是有点慢。




  2. 文档和规范比较差 ,ts-morph 的文档还是太简陋了,挺多核心的方法没有文档描述,不利于维护。




  3. 模块语法不一致 ,TypeScript 的 findAllReferences 并不识别 Dynamic Import 语法,需要额外处理 import() 形式导入的模块。




  4. 删除方案难做 ,ts-prune 封装了相对完善的 dead exports 检测方案,但作者似乎没有做自动删除方案的意思。这时 第二点的劣势就出来了,按照文档来探索删除方案非常艰难。看起来有个德国的小哥 好不容易说服作者 提了一个自动删除的 MR:Add a fix mode that automatically fixes unused exports (revival) ,但是最后因为内存溢出没通过 GithubCI,不了了之了。我个人把这套代码 fork 下来在公司内部的大型项目中跑了一下,也确实是内存溢出 ,看了下自动修复方案的代码,也都是很常规的基于 ts-morph 的 API 调用,猜测是底层 API 的性能问题?




所以综合评估下来,最后还是选择了 ts-unused-exports + ESLint 的方案。


链接:https://juejin.cn/post/6995371411019710500

收起阅读 »

性能优化面试官想听的是什么?别再说那些老掉牙的性能优化了

网上性能优化的文章太多了,都说如何如何请求优化代码优化之类的,所有人都知道的事,而且实际工作中根本不可能每个项目都用到那些全部,而是应该对我们的项目有针对性的优化,你说是吗? 比如 说一下前端性能优化? 你平时是怎么做性能优化的? 等等类似这样的问题,不过就是...
继续阅读 »

网上性能优化的文章太多了,都说如何如何请求优化代码优化之类的,所有人都知道的事,而且实际工作中根本不可能每个项目都用到那些全部,而是应该对我们的项目有针对性的优化,你说是吗?


比如


说一下前端性能优化?


你平时是怎么做性能优化的?


等等类似这样的问题,不过就是换汤不换药罢了


好吧,先上药


这性能优化呢,它是一个特别大的方向,因为每个项目可能优化的点都不一样,每一种框架或者每一种客户端可以优化的点也都不一样


总的来说,现在B/S架构下都是前端向后端请求,后端整理好数据给客户端返回,然后客户端再进行数据处理、到渲染将界面展示出来,这么一个大致流程


那我们优化就是要基于这一过程,说白了我们能够优化的点,就只有两个大的方向


一是更快的网络通信,就是客户端和服务端之间,请求或响应时 数据在路上的时间让它更快


二是更快的数据处理,指的是



  • 服务器接到请求之后,更快的整理出客户端需要的数据

  • 客户端收到响应的数据后,更快的给用户展示 以及 交互时更快的处理


然后!开始blablabla.....




更快网络通信方面比如:CDN做全局负载均衡、CDN缓存、域名分片、资源合并、雪碧图、字体图标、http缓存,以减少请求;还有gzip/br压缩、代码压缩、减少头信息、减少Cookie、使用http2、用jpg/webp、去除元数据等等,blablabla.....


更快数据处理方面比如:SSR、SSG、预解析、懒加载、按需引入、按需加载、CSS放上面、JS放下面、语义化标签、动画能用CSS就不用JS、事件委托、减少重排、等等代码优化,blablabla.....




.....


请直接把上面的总结成一句话 给面试官


请求优化、代码优化、打包优化都是常规操作,像雅虎34条军规,都知道的事就不用说了


因为每个项目优化的点可能都不一样,所以优化主要 还是根据自己的项目来


要么跟人家聊一下框架优化,深入原理也很不错


具体要优化什么主要还是看浏览器(Chrome为例)开发者工具里的 LighthousePerformance


image.png


Lighthouse 是生成性能分析报告。可以看到每一项的数据和评分和建议优化的点,比如哪里图片过大


Performance 是运行时数据,可以看到更多细节数据。比如阻塞啊,重排重绘啊,都会体现出来,够不够细节


然后再去根据这些报告和性能指标体现出来的情况,有针对性的去不断优化我们的项目


Lighthouse


直接在Chrome开发者工具中打开


或者 Node 12.x或以上的版本可以直接安装在本地


npm install -g lighthouse

安装好后,比如生成掘金的性能分析报告,一句代码就够了,然后就会生成一个html文件


lighthouse https://juejin.cn/

不管是浏览器还是安装本地的,生成好的报告都是长的一模一样的,一个英文的html文件,翻译了一张给大家看看,如图


image.png


如图,分析报告内容一共五个大项,每一项满分100分,然后下面是再把每项分别展开说明


还是看一下英文版吧,一个程序员必须要养成这个习惯


image.png


如图,五项分别是



  • Performance:这个又分为三块性能指标可优化的项和手动诊断,看这一块就可以我们就可以优化很多东西了

  • Accessibility:无障碍功能分析。比如前景色和背景色没有足够对比度、图片有没有alt属性、a链接有没有可识别名称等等

  • Best Practices:最佳实践策略。比如图片纵横比不正确,控制台有没有报错信息等

  • SEO:有没有SEO搜索引擎优化的一些东西

  • PWA:官方说法是衡量站点是否快速、可靠和可安装。在国内浏览器内核不统一,小程序又这么火,所以好像没什么落地的场景


然后我们知道了这么多信息,是不是就可以对我们的项目诊断和针对性的优化了呢


是不是很棒


image.png


Performance


如果说 Lighthouse 是开胃菜,那 Performance 就是正餐了


它记录了网站运行过程中性能数据。我们回放整个页面执行过程的话,就可以定位和诊断每个时间段内页面运行情况,不过它没有性能评分,也没有优化建议,只是将采集到的数据按时间线的方式展现


打开 Performance,勾选 Memory,点击左边的 Record 开始录制,然后执行操作或者刷新页面,然后再点一下(Stop)就结束录制,生成数据


image.png


如图


image.png


概况面板


里面有页面帧速(FPS)、白屏时间、CPU资源消耗、网络加载情况、V8内存使用量(堆)等等,按时间顺序展示。


那么怎么看这个图表找到可能存在问题的地方呢




  • 如果FPS(看图右上角)图表上出现红色块,就表示红色块附近渲染出一帧的时间太长了,就有可能导致卡顿




  • 如果CPU图形占用面积太大,表示CPU使用率高,就可能是因为某个JS占用太多主线程时间,阻塞其他任务执行




  • 如果V8的内存使用量一直在增加,就可能因为某种原因导致内存泄露



    • 一次查看内存占用情况后,看当前内存占用趋势图,走势呈上升趋势,可以认为存在内存泄露

    • 多次查看内存占用情况后截图对比,比较每次内存占用情况,如果呈上升趋势,也可以认为存在内存泄露




通过概览面板定位到可能存在问题的时间节点后,怎么拿到更进一步的数据来分析导致该问题的直接原因呢


就是点击时间线上有问题的地方,然后这一块详细内容就会显示在性能面板中


性能面板


比如我们点击时间线上的某个位置(如红色块),性能面板就会显示该时间节点内的性能数据,如图


image.png


性能面板上会列出很多性能指标的项,图中左边,比如



  • Main 指标:是渲染主线程的任务执行记录

  • Timings 指标:记录如FP、FCP、LCP等产生一些关键时间点的数据信息(下面有介绍)

  • Interactions 指标:记录用户交互操作

  • Network 指标:是页面每个请求所耗时间

  • Compositor 指标:是合成线程的任务执行记录

  • GPU 指标:是GPU进程的主线程的任务执行记录

  • Chrome_ChildIOThread 指标:是IO线程的任务执行记录,里面有用户输入事件,网络事件,设备相关等事件

  • Frames 指标:记录每一帧的时间、图层构造等信息

  • .......


Main 指标


性能指标项有很多,而我使用的时候多数时间都是分析Main指标,如图


image.png


上面第一行灰色的,写着 Task 的,一个 Task 就是一个任务


下面黄色紫色的都是啥呢,那是一个任务里面的子任务


我们放大,举个例子


image.png


Task 是一个任务,下面的就是 Task 里面的子任务,这个图用代码表示就是


function A(){
A1()
A2()
}
function Task(){
A()
B()
}
Task()

是不是就好理解得多了


所以我们就可以选中有问题的,比如标红的 Task ,然后放大(滑动鼠标就可放大),看里面具体的耗时点


比如都做了哪些操作,哪些函数耗时了多少,代码有压缩的话看到的就是压缩后的函数名。然后我们点击一下某个函数,在面板最下面,就会出现代码的信息,是哪个函数,耗时多少,在哪个文件上的第几行等。这样我们就很方便地定位到耗时函数了


还可以横向切换 tab ,看它的调用栈等情况,更方便地找到对应代码


具体大家可以试试~


Timings 指标


Timings 指标也需要注意,如图


image.png


它上面的FP、FCP、DCL、L、LCP这些都是个啥呢


别着急


上面说了 Timings 表示一些关键时间点的数据信息,那么表示哪些时间呢,怎么表示的呢?




  • FP表示首次绘制。记录页面第一次绘制像素的时间




  • FCP表示首次内容绘制(只有文本、图片(包括背景图)、非白色的canvas或SVG时才被算作FCP)




  • LCP最大内容绘制,是代表页面的速度指标。记录视口内最大元素绘制时间,这个会随着页面渲染变化而变化




  • FID首次输入延迟,代表页面交互体验的指标。记录FCP和TTI之间用户首次交互的时间到浏览器实际能够回应这种互动的时间




  • CLS累计位移偏移,代表页面稳定的指标。记录页面非预期的位移,比如渲染过程中插入一张图片或者点击按钮动态插入一段内容等,这时候就会触发位移




  • TTI首次可交互时间。指在视觉上已经渲染了,完全可以响应用户的输入了。是衡量应用加载所需时间并能够快速响应用户交互的指标。与FMP一样,很难规范化适用于所有网页的TTI指标定义




  • DCL: 表示HTML加载完成时间


    注意:DCL和L表示的时间在 Performance 和 NetWork 中是不同的,因为 Performance 的起点是点击录制的时间,Network中起点时间是 fetchStart 时间(检查缓存之前,浏览器准备好使用http请求页面文档的时间)




  • L表示页面所有资源加载完成时间




  • TBT阻塞总时间。记录FCP到TTI之间所有长任务的阻塞时间总和




  • FPS每秒帧率。表示每秒钟画面更新次数,现在大多数设备是60帧/秒




  • FMP首次有意义的绘制。是页面主要内容出现在屏幕上的时间,这是用户感知加载体验的主要指标。有点抽象,因为目前没有标准化的定义。因为很难用通用的方式来确定各种类型的页面的关键内容




  • FCI首次CPU空闲时间。表示网页已经满足了最小程度的与用户发生交互行为的时间




好了,然后根据指标体现出来的问题,有针对性的优化就好


结语


点赞支持、手留余香、与有荣焉


感谢你能看到这里,加油哦!


链接:https://juejin.cn/post/6994851822481440781

收起阅读 »

微信小程序中wxs文件的妙用

wxs文件是小程序中的逻辑文件,它和wxml结合使用。 不同于js, wxs可以直接作用到视图层,而不需要进行视图层和逻辑层的setData数据交互; 因为这个特性,wxs非常适合应用于优化小程序的频繁交互操作中; 应用 过滤器 在IOS环境中wxs的运行...
继续阅读 »

wxs文件是小程序中的逻辑文件,它和wxml结合使用。

不同于js, wxs可以直接作用到视图层,而不需要进行视图层和逻辑层的setData数据交互;

因为这个特性,wxs非常适合应用于优化小程序的频繁交互操作中;



应用


过滤器



在IOS环境中wxs的运行速度要远高于js,在android中两者表现相当。

使用wxs作为过滤器也可以一定幅度提升性能;让我们来看一个过滤器来了解其语法。



wxs文件:


var toDecimal2 = function (x) {
var f = parseFloat(x);
if (isNaN(f)) {
return '0.00'
}
var f = Math.round(x * 100) / 100;
var s = f.toString();
var rs = s.indexOf('.');
if (rs < 0) {
rs = s.length;
s += '.';
}
while (s.length <= rs + 2) {
s += '0';
}
return s;
}
module.exports = toDecimal2

上面的代码实现了数字保留两位小数的功能。


wxml文件:


<wxs src="./filter.wxs" module="filter"></wxs>
<text>{{filter(1)}}</text>

基本语法:在视图文件中通过wxs标签引入,module值是自定义命名,之后在wxml中可以通过filter调用方法



上面的代码展示了 wxs的运行逻辑,让我们可以像函数一样调用wxs中的方法;

下面再看一下wxs针对wxml页面事件中的表现。



拖拽



使用交互时(拖拽、上下滑动、左右侧滑等)如果依靠js逻辑层,会需要大量、频繁的数据通信。卡顿是不可避免的;

使用wxs文件替代交互,不需要频繁使用setData导致实时大量的数据通信,从而节省性能。



下面展示一个拖拽例子


wxs文件:


function touchstart(event) {
var touch = event.touches[0] || event.changedTouches[0]
startX = touch.pageX
startY = touch.pageY
}

事件参数event和js中的事件event内容中touches和changedTouches属性一致


function touchmove(event, ins) {
var touch = event.touches[0] || event.changedTouches[0]
ins.selectComponent('.div').setStyle({
left: startX - touch.pageX + 'px',
top: startY - touch.pageY + 'px'
})
}

ins(第二个参数)为触发事件的视图层wxml上下文。可以查找页面所有元素并设置style,class(足够完成交互效果)



注意:在参数event中同样有一个上下文实例instance;

event中的实例instance作用范围是触发事件的元素内,而事件的ins参数作用范围是触发事件的组件内。



module.exports = {
touchstart: touchstart,
touchmove: touchmove,
}

最后将方法抛出去,给wxml文件引用。


wxml文件


<wxs module="action" src="./movable.wxs"></wxs> 
<view class="div" bindtouchstart="{{action.touchstart}}" bindtouchmove="{{action.touchmove}}"></view>


上面的例子,解释了事件的基本交互用法。



文件之中相互传参



在事件交互中,少不了需要各个文件之中传递参数。 下面是比较常用的几种



wxs传参到js逻辑层


wxs文件中:


var dragStart = function (e, ins) {
ins.callMethod('callback','sldkfj')
}

js文件中:


callback(e){
console.log(e)
}
// sldkfj


使用callMethod方法,可以执行js中的callback方法。也可以实现传参;



js逻辑层传参到wxs文件


js文件中:


handler(e){
this.setData({a:1})
}

wxml文件:


<wxs module="action" src="./movable.wxs"></wxs> 
<view change:prop="{{action.change}}" prop="{{a}}"></view>

wxs文件中:


change(newValue,oldValue){}

js文件中的参数传递到wxs需要通过wxml文件中转。

js文件触发handler事件,改变a的值之后,最新的a传递到wxml中。

wxml中prop改变会触发wxs中的change事件。change中则会接收到最新prop值


wxs中获取dataset(wxs中获取wxml数据)


wxs中代码


var dragStart = function (e) {
var index = e.currentTarget.dataset.index;
var index = e.instance.getDataset().index;
}

上面有提到e.instance是当前触发事件的元素实例。

所以e.instance.getDataset()获取的是当前触发事件的dataset数据集


注意点



wxs和js为不同的两个脚本语言。但是语法和es5基本相同,确又不支持es6语法;
getState 在多元素交互中非常实用,欢迎探索。



不知道是否是支持的语法可以跳转官网文档;
wxs运算符、语句、基础类库、数据类型



链接:https://juejin.cn/post/6995065856451411981

收起阅读 »

使用 Electron 开发桌面应用

介绍 Electron,官方简介:使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。 出于个人爱好,接触到了Electron,并开始尝试开发一些本地小工具。 以下是对开发过程做的一个经验总结,便于回顾和交流。 使用 下面来构建一...
继续阅读 »

介绍



Electron,官方简介:使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。

出于个人爱好,接触到了Electron,并开始尝试开发一些本地小工具。

以下是对开发过程做的一个经验总结,便于回顾和交流。



使用



下面来构建一个简单的electron应用。

应用源码地址:github.com/zhuxingmin/…



1. 项目初始化



项目基于 create-react-app@3.3.0 搭建,执行命令生成项目



// 全局安装 create-react-app
npm install -g create-react-app

// 执行命令生成项目
create-react-app electronApp

// 安装依赖并启动项目
yarn && yarn start


此时启动的只是一个react应用,下一步安装 electron electron-updater electron-builder electron-is-dev等库



yarn add electron electron-updater electron-builder electron-is-dev

2. 配置package.json



安装完项目依赖后,在package.json中添加electron应用相关配置。



"version": "0.0.1"              // 设置应用版本号 
"productName": "appName" // 设置应用名称
"main": "main.js" // 设置应用入口文件
"homepage": "." // 设置应用根路径


scripts中添加应用命令,启动以及打包。



"estart": "electron ."              // 启动
"package-win": "electron-builder" // 打包 (此处以windows平台为例,故命名为package-win)


新增build配置项,添加打包相关配置。

主要有以下几个配置:


"build": {
// 自定义appId 一般以安装路径作为id windows下可以在 PowerShell中输入Get-StartApps查看应用id
"appId": "org.develar.zhuxingmin",
// 打包压缩 "store" | "normal"| "maximum"
"compression": "store",
// nsis安装配置
"nsis": {
"oneClick": false, // 一键安装
"allowToChangeInstallationDirectory": true, // 允许修改安装目录
// 下面这些配置不常用
"guid": "haha", // 注册表名字
"perMachine": true, // 是否开启安装时权限限制(此电脑或当前用户)
"allowElevation": true, // 允许请求提升。 如果为false,则用户必须使用提升的权限重新启动安装程序。
"installerIcon": "xxx.ico", // 安装图标
"uninstallerIcon": "xxx.ico", //卸载图标
"installerHeaderIcon": "xxx.ico", // 安装时头部图标
"createDesktopShortcut": true, // 创建桌面图标
"createStartMenuShortcut": true, // 创建开始菜单图标
"shortcutName": "lalala" // 图标名称
},
// 应用打包所包含文件
"files": [
"build/**/*",
"main.js",
"source/*",
"service/*",
"static/*",
"commands/*"
],
// 应用打包地址和输出地址
"directories": {
"app": "./",
"output": "dist"
},
// 发布配置 用于配合自动更新
"publish": [
{
// "generic" | "github"
"provider": "generic", // 静态资源服务器
"url": "http://你的服务器目录/latest.yml"
}
],
// 自定义协议 用于唤醒应用
"protocols": [
{
"name": "myProtocol",
"schemes": [
"myProtocol"
]
}
],
// windows打包配置
"win": {
"icon": "build/fav.ico",
// 运行权限
// "requireAdministrator" | "获取管理员权"
// "highestAvailable" | "最高可用权限"
"requestedExecutionLevel": "highestAvailable",
"target": [
{
"target": "nsis"
}
]
},
},

3. 编写入口文件 main.js



众所周知,基于react脚手架搭建的项目,入口文件为index.js,因此在上面配置完成后,我们想要启动electron应用,需要修改项目入口为main.js




  1. 首先在目录下新建main.js文件,并在package.json文件中,修改应用入口字段main的值为main.js

  2. 通过electron提供的BrowserWindow,创建一个窗口实例mainWindow

  3. 通过mainWindow实例方法loadURL, 加载静态资源

  4. 静态资源分两种加载方式:开发和生产;需要通过electron-is-dev判断当前环境;若是开发环境,可以开启调试入口,通过http://localhost:3000/加载本地资源(react项目启动默认地址);若是生产环境,则要关闭调试入口,并通过本地路径找到项目入口文件index.html



大体代码如下



const { BrowserWindow } = require("electron");
const url = require("url");
const isDev = require('electron-is-dev');
mainWindow = new BrowserWindow({
width: 1200, // 初始宽度
height: 800, // 初始高度
minWidth: 1200,
minHeight: 675,
autoHideMenuBar: true, // 隐藏应用自带菜单栏
titleBarStyle: false, // 隐藏应用自带标题栏
resizable: true, // 允许窗口拉伸
frame: false, // 隐藏边框
transparent: true, // 背景透明
backgroundColor: "none", // 无背景色
show: false, // 默认不显示
hasShadow: false, // 应用无阴影
modal: true, // 该窗口是否为禁用父窗口的子窗口
webPreferences: {
devTools: isDev, // 是否开启调试功能
nodeIntegration: true, // 默认集成node环境
},
});

const config = dev
? "http://localhost:3000/"
: url.format({
pathname: path.join(__dirname, "./build/index.html"),
protocol: "file:",
slashes: true,
});

mainWindow.loadURL(config);

4. 项目启动



项目前置操作完成,运行上面配置的命令来启动electron应用



   // 启动react应用,此时应用运行在"http://localhost:3000/"
yarn start
// 再启动electron应用,electron应用会在入口文件`main.js`中通过 mainWindow.loadURL(config) 来加载react应用
yarn estart


文件目录





至此,一个简单的electron应用已经启动,效果图如下(这是示例项目的截图)。



效果图



作为一个客户端应用,它的更新与我们的网页开发相比要显得稍微复杂一些,具体将会通过下面一个应用更新的例子来说明。



5. 应用更新



electron客户端的更新与网页不同,它需要先下载更新包到本地,然后通过覆盖源文件来达到更新效果。




首先第一步,安装依赖



yarn add electron-updater electron-builder
复制代码


应用通过electron-updater提供的api,去上文配置的服务器地址寻找并对比latest.yml文件,如果版本号有更新,则开始下载资源,并返回下载进度相关信息。下载完成后可以自动也可以手动提示用户,应用有更新,请重启以完成更新 (更新是可以做到无感的,下载完更新包之后,可以不提示,下次启动客户端时会自动更新)



// 主进程
const { autoUpdater } = require("electron-updater");
const updateUrl = "应用所在的远程服务器目录"
const message = {
error: "检查更新出错",
checking: "正在检查更新……",
updateAva: "检测到新版本,正在下载……",
updateNotAva: "现在使用的就是最新版本,不用更新",
};
autoUpdater.setFeedURL(updateUrl);
autoUpdater.on("error", (error) => {
sendUpdateMessage("error", message.error);
});
autoUpdater.on("checking-for-update", () => {
sendUpdateMessage("checking-for-update", message.checking);
});
autoUpdater.on("update-available", (info) => {
sendUpdateMessage("update-available", message.updateAva);
});
autoUpdater.on("update-not-available", (info) => {
sendUpdateMessage("update-not-available", message.updateNotAva);
});
// 更新下载进度事件
autoUpdater.on("download-progress", (progressObj) => {
mainWindow.webContents.send("downloadProgress", progressObj);
});
autoUpdater.on("update-downloaded", function (
event,
releaseNotes,
releaseName,
releaseDate,
updateUrl,
quitAndUpdate
) {
ipcMain.on("isUpdateNow", (e, arg) => {
// 接收渲染进程的确认消息 退出应用并更新
autoUpdater.quitAndInstall();
});
//询问是否立即更新
mainWindow.webContents.send("isUpdateNow");
});
ipcMain.on("checkForUpdate", () => {
//检查是否有更新
autoUpdater.checkForUpdates();
});

function sendUpdateMessage(type, text) {
// 将更新的消息事件通知到渲染进程
mainWindow.webContents.send("message", { text, type });
}

// 渲染进程
const { ipcRenderer } = window.require("electron");

// 发送检查更新的请求
ipcRenderer.send("checkForUpdate");

// 设置检查更新的监听频道

// 监听检查更新事件
ipcRenderer.on("message", (event, data) => {
console.log(data)
});

// 监听下载进度
ipcRenderer.on("downloadProgress", (event, data) => {
console.log("downloadProgress: ", data);
});

// 监听是否可以开始更新
ipcRenderer.on("isUpdateNow", (event, data) => {
// 用户点击确定更新后,回传给主进程
ipcRenderer.send("isUpdateNow");
});


应用更新的主要步骤




  1. 在主进程中,通过api获取远程服务器上是否有更新包

  2. 对比更新包的版本号来确定是否更新

  3. 对比结果如需更新,则开始下载更新包并返回当前下载进度

  4. 下载完成后,开发者可选择自动提示还是手动提示或者不提醒(应用在下次启动时会自动更新)



上文演示了在页面上(渲染进程),是如何与主进程进行通信,让主进程去检查更新。

在实际使用中,如果我们需要用到后台的能力或者原生功能时,主进程与渲染进程的交互必不可少。

那么他们有哪些交互方式呢?



在看下面的代码片段之前,可以先了解一下electron主进程与渲染进程
简单来说就是,通过main.js来执行的都属于主进程,其余皆为渲染进程。


6. 主进程与渲染进程间的常用交互方式


// 主进程中使用
const { ipcMain } = require("electron");

// 渲染进程中使用
const { ipcRenderer } = window.require("electron");

方式一



渲染进程 发送请求并监听回调频道



ipcRenderer.send(channel, someRequestParams);
ipcRenderer.on(`${channel}-reply`, (event, result)=>{
// 接收到主进程返回的result
})


主进程 监听请求并返回结果



ipcMain.on(channel, (event, someRequestParams) => {
// 根据someRequestParams,经过操作后得到result
event.reply(`${channel}-reply`, result)
})

方式二



渲染进程



const result = await ipcRenderer.invoke(channel, someRequestParams);


主进程:



ipcMain.handle(channel, (event, someRequestParams) => {
// 根据someRequestParams,经过操作后得到result
return result
});

方式三
以上两种方式均为渲染进程通知主进程, 第三种是主进程通知渲染进程



主进程



/*
* 使用`BrowserWindow`初始化的实例`mainWindow`
*/
mainWindow.webContents.send(channel, something)


渲染进程



ipcRenderer.on(channel, (event, something) => {
// do something
})

上文的应用更新用的就是方式一


还有其它通讯方式postMessage, sendTo等,可以根据具体场景决定使用何种方式。


7. 应用唤醒(与其他应用联动)


electron应用除了双击图标运行之外,还可以通过协议链接启动(浏览器地址栏或者命令行)。这使得我们可以在网页或者其他应用中,以链接的形式唤醒该应用。链接可以携带参数 例:zhuxingmin://?a=1&b=2&c=3 ‘自定义协议名:zhuxingmin’ ‘参数:a=1&b=2&c=3’。


我们可以通过参数,来使应用跳转到某一页或者让应用做一些功能性动作等等。


const path = require('path');
const { app } = require('electron');

// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock();

// 如果获取失败,证明已有实例在运行,直接退出
if (!gotTheLock) {
app.quit();
}

const args = [];
// 如果是开发环境,需要脚本的绝对路径加入参数中
if (!app.isPackaged) {
args.push(path.resolve(process.argv[1]));
}
// 加一个 `--` 以确保后面的参数不被 Electron 处理
args.push('--');
const PROTOCOL = 'zhuxingmin';
// 设置自定义协议
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, args);

// 如果打开协议时,没有其他实例,则当前实例当做主实例,处理参数
handleArgv(process.argv);

// 其他实例启动时,主实例会通过 second-instance 事件接收其他实例的启动参数 `argv`
app.on('second-instance', (event, argv) => {
if (process.platform === 'win32') {
// Windows 下通过协议URL启动时,URL会作为参数,所以需要在这个事件里处理
handleArgv(argv);
}
});

// macOS 下通过协议URL启动时,主实例会通过 open-url 事件接收这个 URL
app.on('open-url', (event, urlStr) => {
handleUrl(urlStr);
});

function handleArgv(argv) {
const prefix = `${PROTOCOL}:`;
const offset = app.isPackaged ? 1 : 2;
const url = argv.find((arg, i) => i >= offset && arg.startsWith(prefix));
if (url) handleUrl(url);
}

function handleUrl(urlStr) {
// myapp://?a=1&b=2
let paramArr = urlStr.split("?")[1].split("&");
const params = {};
paramArr.forEach((item) => {
if (item) {
const [key, value] = item.split("=");
params[key] = value;
}
});
/**
{
a: 1,
b: 2
}
*/

}

链接:https://juejin.cn/post/6995077640566603789

收起阅读 »

H5 性能极致优化

项目背景 H5 项目是企鹅辅导的核心项目,已迭代四年多,包括了课程详情页/老师详情页/报名页/支付页面等页面,构建产物用于企鹅辅导 APP/H5(微信/QQ/浏览器),迭代过程中了也累积了一些性能问题导致页面加载、渲染速度变慢, 为了提升用户体验,近期启动了“...
继续阅读 »

项目背景


H5 项目是企鹅辅导的核心项目,已迭代四年多,包括了课程详情页/老师详情页/报名页/支付页面等页面,构建产物用于企鹅辅导 APP/H5(微信/QQ/浏览器),迭代过程中了也累积了一些性能问题导致页面加载、渲染速度变慢, 为了提升用户体验,近期启动了“H5 性能优化”项目,针对页面加载速度,渲染速度做了专项优化,下面是对本次优化的总结,包括以下几部分内容。



  1. 性能优化效果展示

  2. 性能指标及数据采集

  3. 性能分析方法及环境准备

  4. 性能优化具体实践


一、性能指标及数据采集


企鹅辅导 H5 采用的性能指标包括:


1.页面加载时间:页面以多快的速度加载和渲染元素到页面上。



  • First contentful paint (FCP): 测量页面开始加载到某一块内容显示在页面上的时间。

  • Largest contentful paint (LCP): 测量页面开始加载到最大文本块内容或图片显示在页面中的时间。

  • DomContentLoaded Event:DOM解析完成时间

  • OnLoad Event:页面资源加载完成时间


2.加载后响应时间:页面加载和执行js代码后多久能响应用户交互。



  • First input delay (FID): 测量用户首次与网站进行交互(例如点击一个链接、按钮、js自定义控件)到浏览器真正进行响应的时间。


3.视觉稳定性:页面元素是否会以用户不期望的方式移动,并干扰用户的交互。



  • Cumulative layout shift (CLS): 测量从页面开始加载到状态变为隐藏过程中,发生不可预期的layout shifts的累积分数。


项目使用了 IMLOG 进行数据上报,ELK 体系进行现网数据监控,Grafana 配置视图,观察现网情况。


根据指标的数据分布,能及时发现页面数据异常采取措施。


二、性能分析及环境准备


现网页面情况:



可以看到进度条在页面已经展示后还在持续 loading,加载时间长达十几秒,比较影响了用户体验。


根据 Google 开发文档 对浏览器架构的解释:



当导航提交完成后,渲染进程开始着手加载资源以及渲染页面。一旦渲染进程“完成”(finished)渲染,它会通过IPC告知浏览器进程(注意这发生在页面上所有帧(frames)的 onload 事件都已经被触发了而且对应的处理函数已经执行完成了的时候),然后UI线程就会停止导航栏上旋转的圈圈



我们可以知道,进度条的加载时长和 onload 时间密切相关,要想进度条尽快结束就要 减少 onload时长。


根据现状,使用ChromeDevTool作为基础的性能分析工具,观察页面性能情况


Network:观察网络资源加载耗时及顺序


Performace:观察页面渲染表现及JS执行情况


Lighthouse:对网站进行整体评分,找出可优化项


下面以企鹅辅导课程详情页为案例进行分析,找出潜在的优化项


(注意使用Chrome 隐身窗口并禁用插件,移除其他加载项对页面的影响)


1. Network 分析


通常进行网络分析需要禁用缓存、启用网络限速(4g/3g) 模拟移动端弱网情况下的加载情况,因为wifi网络可能会抹平性能差距。



可以看到DOMContentLoaded的时间在 6.03s ,但onload的时间却在 20.92s


先观察 DOMContentLoaded 阶段,发现最长请求路径在 vendor.js ,JS大小为170kB,花费时间为 4.32s


继续观察 DOMContentLoaded 到 onload 的这段时间



可以发现onload事件被大量媒体资源阻塞了,关于 onload 事件的影响因素,可以参考这篇文章


结论是 浏览器认为资源完全加载完成(HTML解析的资源 和 动态加载的资源)才会触发 onload


结合上图 可以发现加载了图片、视频、iFrame等资源,阻塞了 onload 事件的触发


Network 总结



  1. DOM的解析受JS加载和执行的影响,尽量对JS进行压缩、拆分处理(HTTP2下),能减少 DOMContentLoaded 时间

  2. 图片、视频、iFrame等资源,会阻塞 onload 事件的触发,需要优化资源的加载时机,尽快触发onload


2. Performance 分析


使用Performance模拟移动端注意手机处理器能力比PC差,所以一般将 CPU 设置为 4x slowdown 或 6x slowdown 进行模拟



观察几个核心的数据



  1. Web Vitals ( FP / FCP / LCP / Layout Shift ) 核心页面指标 和 Timings 时长


可以看到 LCP、DCL和 Onload Event 时间较长,且出现了多次 Layout Shift。


要 LCP 尽量早触发,需要减少页面大块元素的渲染时间,观察 Frames 或ScreenShots 的截图,关注页面的元素渲染情况。


可以通过在 Experience 行点击Layout Shift ,在 Summary 面板找到具体的偏移内容。




  1. Main Long Tasks 长任务数量和时长


可以看到页面有大量的Long Tasks需要进行优化,其中couse.js(页面代码)的解析执行时间长达800ms。


处理Long Tasks,可以在开发环境进行录制,这样在 Main Timeline 能看到具体的代码执行文件和消耗时长。


Performance 总结



  1. 页面LCP触发时间较晚,且出现多次布局偏移,影响用户体验,需要尽早渲染内容和减少布局偏移

  2. 页面 Long Tasks 较多,需要对 JS进行合理拆分和加载,减少 Long Tasks 数量,特别是 影响 DCL 和 Onload Event 的 Task


3. Lighthouse 分析


使用ChromeDevTool 内置 lighthouse 对页面进行跑分



分数较低,可以看到 Metrics 给出了核心的数据指标,这边显示的是 TTI SI TBT 不合格,LCP 需要提升,FCP 和 CLS 达到了良好的标准,可以查看分数计算标准


同时 lighthouse 会提供一些 优化建议,在 Oppotunities 和 Diagnostics 项,能看到具体的操作指南,如 图片大小、移除无用JS等,可以根据指南进行项目的优化。


lighthouse 的评分内容是根据项目整体加载项目进行打分的,审查出的问题同样包含Network、Performance的内容,所以也可以看作是对 Network、Performance问题的优化建议。


Lighthouse 总结



  1. 根据评分,可以看出 TTI、SI、TBT、LCP这四项指标需要提高,可以参考lighthouse 文档进行优化。

  2. Oppotunities 和 Diagnostics 提供了具体的优化建议,可以参考进行改善。


4. 环境准备


刚才是对线上网页就行初步的问题分析,要实际进行优化和观察,需要进行环境的模拟,让优化效果能更真实在测试环境中体现。


代理使用:whistle、charles、fiddler等


本地环境、测试环境模拟:nginx、nohost、stke等


数据上报:IMLOG、TAM、RUM等


前端代码打包分析:webpack-bundle-analyzer 、rollup-plugin-visualizer等


分析问题时使用本地代码,本地模拟线上环境验证优化效果,最后再部署到测试环境验证,提高开发效率。


三、性能优化具体实践


PART1: 加载时间优化


Network 中对页面中加载的资源进行分类


第一部分是影响 DOM解析的JS资源,可以看到这里分类为 关键JS和非关键JS,是根据是否参与首面渲染划分的


这里的非关键JS我们可以考虑延迟异步加载,关键JS进行拆分优化处理


1. 关键JS打包优化



JS 文件数量8个,总体积 460.8kB,最大文件 170KB


1.1 Splitchunks 的正确配置

vendor.js 170kB(gzipd) 是所有页面都会加载的公共文件,打包规则是 miniChunks: 3,引用超过3次的模块将被打进这个js




分析vendor.js的具体构成(上图)


以string-strip-html.umd.js 为例 大小为34.7KB,占了 vendor.js的 20%体积,但只有一个页面多次使用到了这个包,触发了miniChunks的规则,被打进了vendor.js。


同理对vendor.js的其他模块进行分析,iosSelect.js、howler.js、weixin-js-sdk等模块都只有3、4个页面/组件依赖,但也同样打进了 vendor.js。


由上面的分析,我们可以得出结论:不能简单的依靠miniChunks规则对页面依赖模块进行抽离打包,要根据具体情况拆分公共依赖。


修改后的vendor根据业务具体的需求,提取不同页面和组件都有的共同依赖(imutils/imlog/qqapi)


vendor: {
test({ resource }) {
return /[\\/]node_modules[\\/](@tencent\/imutils|imlog\/)|qqapi/.test(resource);
},
name: 'vendor',
priority: 50,
minChunks: 1,
reuseExistingChunk: true,
},

而其他未指定的公共依赖,新增一个common.js,将阈值调高到20或更高(当前页面数76),让公共依赖成为大多数页面的依赖,提高依赖缓存利用率,调整完后,vendor.js 的大小减少到 30KB,common.js 大小为42KB


两个文件加起来大小为 72KB,相对于优化前体积减少了 60%(100KB)


1.2 公共组件的按需加载


course.js 101kB (gzipd) 这个文件是页面业务代码的文件



观察上图,基本都是业务代码,除了一个巨大的** component Icon,占了 25k**,页面文件1/4的体积,但在代码中使用到的 Icon 总共才8个


分析代码,可以看到这里使用require加载svg,Webpack将require文件夹内的内容一并打包,导致页面 Icon 组件冗余



如何解决这类问题实现按需加载?


按需加载的内容应该为独立的组件,我们将之前的单一入口的 ICON 组件(动态dangerouslySetInnerHTML)改成单文件组件模式直接引入使用图标。



但实际开发中这样会有些麻烦,一般需要统一的 import 路径,指定需要的图标再加载,参考 babel-plugin-import,我们可以配置 babel 的依赖加载路径调整 Icon 的引入方式,这样就实现了图标的按需加载。



按需加载后,重新编译,查看打包带来的收益,页面的 Icons 组件 stat size 由 74KB 降到了 20KB,体积减少了 70%


1.3 业务组件的代码拆分 (Code Splitting)


观察页面,可以看到”课程大纲“、”课程详情“、”购课须知“这三个模块并不在页面的首屏渲染内容里,



我们可以考虑对页面这几部分组件进行拆分再延迟加载,减少业务代码JS大小和执行时长


拆分的方式很多,可以使用react-loadable、@loadable/component 等库实现,也可以使用React 官方提供的React.lazy


拆分后的代码



代码拆分会导致组件会有渲染的延迟,所以在项目中使用应该综合用户体验和性能再做决定,通过拆分也能使部分资源延后加载优化加载时间。


1.4 Tree Shaking 优化


项目中使用了 TreeShaking的优化,用时候要注意 sideEffects 的使用场景,以免打包产物和开发不一致。


经过上述优化步骤,整体打包内容:



JS 文件数量6个,总体积 308KB,最大文件体积 109KB


关键 JS 优化数据对比:



























文件总体积最大文件体积
优化前460.8 kb170 kb
优化后308 kb109 kb
优化效果总体积减少 50%最大文件体积减少 56%

2.非关键 JS 延迟加载


页面中包含了一些上报相关的 JS 如 sentry,beacon(灯塔 SDK)等,对于这类资源,如果在弱网情况,可能会成为影响 DOM 解析的因素


为了减少这类非关键JS的影响,可以在页面完成加载后再加载非关键JS,如sentry官方也提供了延迟加载的方案


在项目中还发现了一部分非关键JS,如验证码组件,为了在下一个页面中能利用缓存尽快加载,所以在上一个页面提前加载一次生成缓存



如果不访问下一个页面,可以认为这是一次无效加载,这类的提前缓存方案反而会影响到页面性能。


针对这里资源,我们可以使用 Resource Hints,针对资源做 Prefetch 处理


检测浏览器是否支持 prefech,支持的情况下我们可以创建 Prefetch 链接,不支持就使用旧逻辑直接加载,这样能更大程度保证页面性能,为下一个页面提供提前加载的支持。


const isPrefetchSupported = () => {
const link = document.createElement('link');
const { relList } = link;

if (!relList || !relList.supports) {
return false;
}
return relList.supports('prefetch');
};
const prefetch = () => {
const isPrefetchSupport = isPrefetchSupported();
if (isPrefetchSupport) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.as = type;
link.href = url;
document.head.appendChild(link);
} else if (type === 'script') {
// load script
}
};

优化效果:非关键JS不影响页面加载




3.媒体资源加载优化


3.1 加载时序优化


可以观察到onload被大量的图片资源和视频资源阻塞了,但是页面上并没有展示对应的图片或视频,这部分内容应该进行懒加载处理。



处理方式主要是要控制好图片懒加载的逻辑(如 onload 后再加载),可以借助各类 lazyload 的库去实现。 H5项目用的是位置检测(getBoundingClientRect )图片到达页面可视区域再展示。


但要注意懒加载不能阻塞业务的正常展示,应该做好超时处理、重试等兜底措施


3.2 大小尺寸优化


课程详情页 每张详情图的宽为 1715px,以6s为基准(375px)已经是 4x图了,大图片在弱网情况下会影响页面加载和渲染速度



使用CDN 图床尺寸大小压缩功能,根据不同的设备渲染不同大小的图片调整图片格式,根据网络情况,渲染不同清晰度的图



可以看到在弱网(移动3G网络)的情况下,同一张图片不同尺寸加载速度最高和最低相差接近6倍,给用户的体验截然不同


CDN配合业务具体实现:使用 img 标签 srcset/sizes 属性和 picutre 标签实现响应式图片,具体可参考文档


使用URL动态拼接方式构造url请求,根据机型宽度和网络情况,判断当前图片宽度倍数进行调整(如iphone 1x,ipad 2x,弱网0.5x)


优化效果:移动端 正常网络情况下图片体积减小 220%、弱网情况下图片体积减小 13倍


注意实际业务中需要视觉同学参与,评估图片的清晰度是否符合视觉标准,避免反向优化!


3.3 其他类型资源优化


iframe


加载 iframe 有可能会对页面的加载产生严重的影响,在 onload 之前加载会阻塞 onload 事件触发,从而阻塞 loading,但是还存在另一个问题


如下图所示,页面在已经 onload 的情况下触发 iframe 的加载,进度条仍然在不停的转动,直到 iframe 的内容加载完成。



可以将iframe的时机放在 onload 之后,并使用setTimeout触发异步加载iframe,可避免iframe带来的loading影响


数据上报


项目中使用 image 的数据上报请求,在正常网络情况下可能感受不到对页面性能的影响


但在一些特殊情况,如其中一个图片请求的耗时特别长就会阻塞页面 onload 事件的触发,延长 loading 时间



解决上报对性能的影响问题有以下方案



  1. 延迟合并上报

  2. 使用 Beacon API

  3. 使用 post 上报


H5项目采用了延迟合并上报的方案,业务可根据实际需要进行选择


优化效果:全部数据上报在onload后处理,避免对性能产生影响。



字体优化


项目中可能会包含很多视觉指定渲染的字体,当字体文件比较大的时候,也会影响到页面的加载和渲染,可以使用 fontmin 将字体资源进行压缩,生成精简版的字体文件、


优化前:20kB => 优化后:14kB



PART2: 页面渲染优化


1.直出页面 TTFB 时间优化


目前我们在STKE部署了直出服务,通过监控发现直出平均耗时在 300+ms


TTFB时间在 100 ~ 200 之间波动,影响了直出页面的渲染



通过日志打点、查看 Nginx Accesslog 日志、网关监控耗时,得出以下数据(如图)



  • STKE直出程序耗时是 20ms左右

  • 直出网关NGW -> STKE 耗时 60ms 左右

  • 反向代理网关NGINX -> NGW 耗时 60ms 左右


登陆 NGW 所在机器,ping STKE机器,有以下数据


平均时延在 32ms,tcp 三次握手+返回数据(最后一次 ack 时发送数据)= 2个 rtt,约 64ms,和日志记录的数据一致


查看 NGW 机器所在区域为天津,STKE 机器所在区域为南京,可以初步判断是由机房物理距离导致的网络时延,如下图所示



切换NGW到南京机器 ping STKE南京的机器,有以下数据:


同区域机器 ping 的网络时延只有 0.x毫秒,如下图所示:


综合上述分析,直出页面TTFB时间过长的根本原因是:NGW 网关部署和 Nginx、STKE 不在同一区域,导致网络时延的产生


解决方案是让网关和直出服务机房部署在同一区域,执行了以下操作:



  • NGW扩容

  • 北极星开启就近访问


优化前


优化后


优化效果如上图:



















七天网关平均耗时
优化前153 ms
优化后31 ms 优化 80%(120 ms)

2.页面渲染时间优化


模拟弱网情况(slow 3g)Performance 录制页面渲染情况,从下图Screenshot中可以发现



  1. DOM 开始解析,但页面还未渲染

  2. CSS 文件下载完成后页面才正常渲染


CSS不会阻塞页面解析,但会阻塞页面渲染,如果CSS文件较大或弱网情况,会影响到页面渲染时间,影响用户体验。


借助 ChromeDevTool 的 Coverage 工具(More Tools里面),录制页面渲染时CSS的使用率



发现首屏的CSS使用率才15%,可以考虑对页面首屏的关键CSS进行内联让页面渲染不被CSS阻塞,再把完整CSS加载进来


实现Critial CSS 的优化可以考虑使用 critters


优化后效果:


CSS 资源正在下载时,页面已经能正常渲染显示了,对比优化前,渲染时间上 提升了 1~2 个 css 文件加载的时间。



3. 页面布局抖动优化


观察页面的元素变化



优化前(左图):图标缺失、背景图缺失、字体大小改变导致页面抖动、出现非预期页面元素导致页面抖动


优化后:内容相对固定, 页面元素出现无突兀感



主要优化内容:



  1. 确定直出页面元素出现位置,根据直出数据做好布局

  2. 页面小图可以通过base64处理,页面解析的时候就会立即展示

  3. 减少动态内容对页面布局的影响,使用脱离文档流的方式或定好宽高


四、性能优化效果展示


优化效果由以下指标量化


首次内容绘制时间FCP(First Contentful Paint):标记浏览器渲染来自 DOM 第一位内容的时间点


视窗最大内容渲染时间LCP(Largest Contentful Paint):代表页面可视区域接近完整渲染


加载进度条时间:浏览器 onload 事件触发时间,触发后导航栏进度条显示完成


Chrome 模拟器 4G 无缓存对比(左优化前、右优化后)























首屏最大内容绘制时间进度条加载(onload)时间
优化前1067 ms6.18s
优化后31 ms 优化 80%(120 ms)1.19s 优化 81%

Lighthouse 跑分对比


优化前


优化后



srobot 性能检测一周数据



srobot 是团队内的性能检测工具,使用TRobot指令一键创建页面健康检测,定时自动化检测页面性能及异常



优化前


优化后


五、优化总结和未来规划



  1. 以上优化手段主要是围绕首次加载页面的耗时和渲染优化,但二次加载还有很大的优化空间 如 PWA 的使用、非直出页面骨架屏处理、CSR 转 SSR等

  2. 对比竞品发现我们 CDN 的下载耗时较长,近期准备启动 CDN 上云,期待上云后 CDN 的效果提升。

  3. 项目迭代一直在进行,需要思考在工程上如何持续保障页面性能

  4. 上文是围绕课程详情页进行的分析和优化处理,虽然对项目整体做了优化处理,但性能优化没有银弹,不同页面的优化要根据页面具体需求进行,需要开发同学主动关注。

链接:https://juejin.cn/post/6994383328182796295

收起阅读 »

code review 流程探索

前言 没有无缘无故的爱,也没有无缘无故的恨,当然也没有无缘无故的 code review 为什么要 CR 给大家讲个故事,“大神 A”上班时突然恼羞成怒的骂道,这是谁写的代码,没有注释啥也没有,这么明显的 bug。当时整个小组都不敢说话,慌的要死,生怕说的就是...
继续阅读 »

前言


没有无缘无故的爱,也没有无缘无故的恨,当然也没有无缘无故的 code review


为什么要 CR


给大家讲个故事,“大神 A”上班时突然恼羞成怒的骂道,这是谁写的代码,没有注释啥也没有,这么明显的 bug。当时整个小组都不敢说话,慌的要死,生怕说的就是自己。领导发话:“大神 A”查下提交记录,谁提交的谁请吃饭。过了两分钟,“大神 A”:这,这是我自己一年前提交的。所以不想自己尴尬,赶紧 code review 吧


一、角色职能



author 即需求开发者。要求:



  1. 注重注释。对复杂业务写明相应注释,commit 写名具体提交背景,便于 reviewer 理解。

  2. 端正心态接受他人 review。对 reviewer 给出的 comment,不要有抵触的情绪,对你觉得不合理的建议,可以委婉地进行拒绝,或者详细说明自己的看法以及原因。reviewer 持有的观点并不一定是合理的,所以 review 也是一个相互学习的过程。

  3. 完成 comment 修改后及时反馈。commit 提交信息备注如"reivew: xxxx",保证复检效率。


reviewer 作为 cr 参与者,建议由项目责任人和项目参与者组成。要求:



  1. 说明 comment 等级。reviewer 对相应代码段提出评价时,需要指明对应等级,如

    • fix: xxxxxxx 此处需强制修改,提供修改建议

    • advise: xxxxxxx 此处主观上建议修改,不强制,可提供修改建议

    • question: xxxxxx 此处存在疑虑,需要 author 作出解释



  2. 友好 comment。评价注意措辞,可以说“我们可以如何去调整修改,可能会更合适。。。”,对于比较好的代码,也应该给与足够的赞美。

  3. 享受 review。避免以挑毛病的心态 review,好的 reviewer 并不是以提的问题多来衡量的。跳出自己的编码风格,主动理解 author 的思路,也是一个很好的学习过程。


二、CR 流程


1、self-review



  • commit 之前要求 diff 一下,查看文件变更情况,可接着 gitk 完成。当然如果项目使用 pre-commit 关联 lint 校验,也能发现例如 debugger、console.log 之类语句。但是仍然提倡大家每次提交之前检查一下提交文件。

  • 多人协作下的 commit。多人合作下的分支在合并请求时,需要关注是否带入没必要的 commit。

  • commit message。建议接入 husky、commitlint/cli 以及 commitlint/config-conventional 校验 commit message。commitlint/config-conventional 所提供的类型如

    • feat: 新特性

    • fix: 修改 bug

    • chore: 优化,如项目结构,依赖安装更新等

    • docs: 文档变更

    • style: 样式相关修改

    • refactor:项目重构




此目的为了进一步增加 commit message 信息量,帮助 reviewer 以及自己更有效的了解 commit 内容。


2、CR



  1. 提测时发起 cr,需求任务关联 reviewer。提供合并请求,借助 gitlab/sourcetree/vscode gitlens 等工具。reviewer 结束后给与反馈

  2. 针对 reviewer 提出的建议修改之后,commit message 注明类似'review fix'相关信息,便于 reviewer 复检。

  3. 紧急需求,特事特办,跳过 cr 环节,事后 review。


三、CR 标准



  1. 不纠结编码风格。编码风格交给 eslint/tslint/stylelint

  2. 代码性能。大数据处理、重复渲染等

  3. 代码注释。字段注释、文档注释等

  4. 代码可读性。过多嵌套、低效冗余代码、功能独立、可读性变量方法命名等

  5. 代码可扩展性。功能方法设计是否合理、模块拆分等

  6. 控制 review 时间成本。reviewer 尽量由项目责任人组成,关注代码逻辑,无需逐字逐句理解。


四、最后


总的来说,cr 并不是一个找 bug 挑毛病的过程,更不会降低整体开发效率。其目的是为了保证项目的规范性,使得其他开发人员在项目扩展和维护时节省更多的时间和精力。当然 cr 环节需要团队每一个成员去推动,只有每一个人都认可且参与进来,才能发挥 cr 的最大价值。


f5e284a8e87e4340b5f20e9c88fb2777_tplv-k3u1fbpfcp-zoom-1.gif


最后安利一波本人开发vscode小插件搭配gitlab进行review。因为涉及内部代码,暂时不能对外开放,这里暂时提供思路,后续开放具体代码。



链接:https://juejin.cn/post/6994217066328752164

收起阅读 »

还不会Hook?一份React Hook学习笔记

Hook 是 React 16.8.0 版本增加的新特性,可以在函数组件中使用 state 以及其他的 React 特性。 ✌️为什么要使用 Hook? 在组件之间复用状态逻辑很难 由providers,consumers,高阶组件,render prop...
继续阅读 »

Hook 是 React 16.8.0 版本增加的新特性,可以在函数组件中使用 state 以及其他的 React 特性。


✌️为什么要使用 Hook?




  • 在组件之间复用状态逻辑很难


    providersconsumers,高阶组件,render props等其他抽象层组成的组件会形成嵌套地狱,使用 Hook 可以从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使你在无需修改组件结构的情况下复用状态逻辑




  • 复杂组件难以理解


    每个生命周期常常包含一些不相关的逻辑。例如,组件常常在 componentDidMountcomponentDidUpdate 中获取数据。但是,同一个 componentDidMount 中可能也包含很多其它的逻辑,如设置事件监听,而之后需在 componentWillUnmount 中清除。相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起。




  • 难以理解的 class




下面介绍几个常用的Hook。


2. useState


useState让函数组件也可以有state状态,并进行状态数据的读写操作。


const [xxx, setXxx] = useState(initValue); // 解构赋值

📐useState() 方法


参数


第一次初始化指定的值在内部作缓存。可以按照需要使用数字字符串对其进行赋值,而不一定是对象


如果想要在state中存储两个不同的变量,只需调用 useState() 两次即可。


返回值


包含2个元素的数组,第1个为内部当前状态值,第2个为更新状态值的函数,一般直接采用解构赋值获取。


📐setXxx() 的写法


setXxx(newValue):参数为非函数值,直接指定新的状态值,内部用其覆盖原来的状态值


setXxx(value => newValue):参数为函数接收原本的状态值,返回新的状态值,内部用其覆盖原来的状态值。


📐 完整示例


const App = () => {
const [count, setCount] = useState(0);

const add = () => {
// 第一种写法
// setCount(count + 1);
// 第二种写法
setCount(count => count + 1);
};

return (
<Fragment>
<h2>当前求和为:{count}</h2>
<button onClick={add}>点我+1</button>
</Fragment>
);
};

useState就是一个 Hook,唯一的参数就是初始state,在这里声明了一个叫count的 state 变量,然后把它设为0。React会在重复渲染时记住它当前的值,并且提供最新的值给我们的函数。我们可以通过调用setCount来更新当前的count


在函数中,我们可以直接用 count


<h2>当前求和为:{count}</h2>

更新state


setCount(count + 1);
setCount(count => count + 1);

📐 使用多个 state 变量


可以在一个组件中多次使用State Hook


// 声明多个 state 变量
const [age, setAge] = useState(42);
const [fruit, setFruit] = useState('banana');
const [todos, setTodos] = useState([{ text: '学习 Hook' }]);

📌不是必须要使用多个state变量,仍然可以将相关数据分为一组。但是,不像 class 中的 this.setStateuseState中更新state变量是替换。不是合并


3. useEffect


useEffect可以在函数组件中执行副作用操作(用于模拟类组件中的生命周期钩子)。


React 中的副作用操作



  • ajax 请求数据获取

  • 设置订阅 / 启动定时器

  • 手动更改真实 DOM


📐 使用规则


useEffect(() => {
// 在此可以执行任何带副作用操作
// 相当于componentDidMount()
return () => {
// 在组件卸载前执行
// 在此做一些收尾工作, 比如清除定时器/取消订阅等
// 相当于componentWillUnmount()
};
}, [stateValue]); // 监听stateValue
// 如果省略数组,则检测所有的状态,状态有更新就又调用一次回调函数
// 如果指定的是[], 回调函数只会在第一次render()后执行一次

可以把 useEffect 看做如下三个函数的组合:



  • componentDidMount()

  • componentDidUpdate()

  • componentWillUnmount()


📐 每次更新的时候都运行 Effect


// ...
useEffect(() => {
// ...
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});

调用一个新的 effect 之前会对前一个 effect 进行清理。下面按时间列出一个可能会产生的订阅和取消订阅操作调用序列:


// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // 运行第一个 effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // 清除上一个 effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // 运行下一个 effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // 清除上一个 effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // 运行下一个 effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // 清除最后一个 effect

📐 通过跳过 Effect 进行性能优化


如果某些特定值在两次重渲染之间没有发生变化,可以通知 React 跳过对 effect 的调用,只要传递数组作为 useEffect第二个可选参数即可:


useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新

如果 count 的值是 5,而且组件重渲染的时候 count 还是等于 5,React 将对前一次渲染的 [5] 和后一次渲染的 [5] 进行比较。因为数组中的所有元素都是相等的(5 === 5),React 会跳过这个 effect,这就实现了性能的优化。


当渲染时,如果 count 的值更新成了 6,React 将会把前一次渲染时的数组 [5] 和这次渲染的数组 [6] 中的元素进行对比。这次因为 5 !== 6,React 就会再次调用 effect。


📌 如果数组中有多个元素,即使只有一个元素发生变化,React 也会执行 effect。


对于有清除操作的 effect 同样适用:


useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // 仅在 props.friend.id 发生变化时,重新订阅

📐 使用多个 Effect 实现关注点分离


使用 Hook 其中一个目的就是要解决 class 中生命周期函数经常包含不相关的逻辑,但又把相关逻辑分离到了几个不同方法中的问题。下述代码是将前述示例中的计数器好友在线状态指示器逻辑组合在一起的组件:


function FriendStatusWithCounter(props) {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});

const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
// ...
}

📐 完整示例


import React, { useState, Fragment, useEffect } from 'react';
import ReactDOM from 'react-dom';

const App = () => {
const [count, setCount] = useState(0);

useEffect(() => {
let timer = setInterval(() => {
setCount(count => count + 1);
}, 500);
console.log('@@@@');
return () => {
clearInterval(timer);
};
}, [count]);
// 检测count的变化,每次变化,都会输出'@@@@'
// 如果是[],则只会输出一次'@@@@'

const add = () => {
setCount(count => count + 1);
};

const unmount = () => {
ReactDOM.unmountComponentAtNode(document.getElementById('root'));
};

return (
<Fragment>
<h2>当前求和为:{count}</h2>
<button onClick={add}>点我+1</button>
<button onClick={unmount}>卸载组件</button>
</Fragment>
);
};

export default App;

4. useRef


useRef可以在函数组件中存储 / 查找组件内的标签或任意其它数据。保存标签对象,功能与 React.createRef() 一样。


const refContainer = useRef(initialValue);

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。


📌 当 ref 对象内容发生变化时,useRef不会通知你。变更 .current 属性不会引发组件重新渲染。


import React, { Fragment, useRef } from 'react';

const Demo = () => {
const myRef = useRef();

//提示输入的回调
function show() {
console.log(myRef.current.value);
}

return (
<Fragment>
<input type="text" ref={myRef} />
<button onClick={show}>点击显示值</button>
</Fragment>
);
};

export default Demo;

5. Hook规则


Hook 就是 JavaScript 函数,但是使用它们会有两个额外的规则:


只在最顶层使用 Hook


不要在循环,条件或嵌套函数中调用 Hook ,在 React 函数的最顶层调用 Hook。


如果想要有条件地执行一个 effect,可以将判断放到 Hook 的内部


useEffect(function persistForm() {
// 👍 将条件判断放置在 effect 中
if (name !== '') {
localStorage.setItem('formData', name);
}
});

只在 React 函数中调用 Hook


不要在普通的 JavaScript 函数中调用 Hook。可以:



  • 在 React 的函数组件中调用 Hook

  • 在自定义 Hook 中调用其他 Hook




链接:https://juejin.cn/post/6992733298493489183
收起阅读 »

图解React源码 - React 应用的3种启动方式

在前文reconciler 运作流程把reconciler的流程归结成 4 个步骤. 本章节主要讲解react应用程序的启动过程, 位于react-dom包, 衔接reconciler 运作流程中的输入步骤. 在正式分析源码之前, 先了解一下react应用的启...
继续阅读 »

在前文reconciler 运作流程reconciler的流程归结成 4 个步骤.


本章节主要讲解react应用程序的启动过程, 位于react-dom包, 衔接reconciler 运作流程中的输入步骤.


在正式分析源码之前, 先了解一下react应用的启动模式:


在当前稳定版react@17.0.2源码中, 有 3 种启动方式. 先引出官网上对于这 3 种模式的介绍, 其基本说明如下:




  1. legacy 模式: ReactDOM.render(<App />, rootNode). 这是当前 React app 使用的方式. 这个模式可能不支持这些新功能(concurrent 支持的所有功能).


    // LegacyRoot
    ReactDOM.render(<App />, document.getElementById('root'), dom => {}); // 支持callback回调, 参数是一个dom对象



  2. Blocking 模式: ReactDOM.createBlockingRoot(rootNode).render(<App />). 目前正在实验中, 它仅提供了 concurrent 模式的小部分功能, 作为迁移到 concurrent 模式的第一个步骤.


    // BolckingRoot
    // 1. 创建ReactDOMRoot对象
    const reactDOMBolckingRoot = ReactDOM.createBlockingRoot(
    document.getElementById('root'),
    );
    // 2. 调用render
    reactDOMBolckingRoot.render(<App />); // 不支持回调



  3. Concurrent 模式: ReactDOM.createRoot(rootNode).render(<App />). 目前在v18.0.0-alpha,和experiment版本中发布. 这个模式开启了所有的新功能.


    // ConcurrentRoot
    // 1. 创建ReactDOMRoot对象
    const reactDOMRoot = ReactDOM.createRoot(document.getElementById('root'));
    // 2. 调用render
    reactDOMRoot.render(<App />); // 不支持回调



注意: 虽然17.0.2的源码中有createRootcreateBlockingRoot方法(如果自行构建, 会默认构建experimental版本), 但是稳定版的构建入口排除掉了这两个 api, 所以实际在npm i react-dom安装17.0.2稳定版后, 不能使用该 api.如果要想体验非legacy模式, 需要显示安装alpha版本(或自行构建).


启动流程


在调用入口函数之前,reactElement(<App/>)和 DOM 对象div#root之间没有关联, 用图片表示如下:


process-before.png


创建全局对象


无论Legacy, Concurrent或Blocking模式, react 在初始化时, 都会创建 3 个全局对象



  1. ReactDOM(Blocking)Root对象





  1. fiberRoot对象



    • 属于react-reconciler包, 作为react-reconciler在运行过程中的全局上下文, 保存 fiber 构建过程中所依赖的全局状态.

    • 其大部分实例变量用来存储fiber 构造循环(详见两大工作循环)过程的各种状态.react 应用内部, 可以根据这些实例变量的值, 控制执行逻辑.




  2. HostRootFiber对象



    • 属于react-reconciler包, 这是 react 应用中的第一个 Fiber 对象, 是 Fiber 树的根节点, 节点的类型是HostRoot.




这 3 个对象是 react 体系得以运行的基本保障, 一经创建大多数场景不会再销毁(除非卸载整个应用root.unmount()).


这一过程是从react-dom包发起, 内部调用了react-reconciler包, 核心流程图如下(其中红色标注了 3 个对象的创建时机).


function-call.png


下面逐一解释这 3 个对象的创建过程.


创建 ReactDOM(Blocking)Root 对象


由于 3 种模式启动的 api 有所不同, 所以从源码上追踪, 也对应了 3 种方式. 最终都 new 一个ReactDOMRootReactDOMBlockingRoot的实例, 需要创建过程中RootTag参数, 3 种模式各不相同. 该RootTag的类型决定了整个 react 应用是否支持可中断渲染(后文有解释).


下面根据 3 种 mode 下的启动函数逐一分析.


legacy 模式


legacy模式表面上是直接调用ReactDOM.render, 跟踪ReactDOM.render后续调用legacyRenderSubtreeIntoContainer(源码链接)


function legacyRenderSubtreeIntoContainer(
parentComponent: ?React$Component<any, any>,
children: ReactNodeList,
container: Container,
forceHydrate: boolean,
callback: ?Function,
) {
let root: RootType = (container._reactRootContainer: any);
let fiberRoot;
if (!root) {
// 初次调用, root还未初始化, 会进入此分支
//1. 创建ReactDOMRoot对象, 初始化react应用环境
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(
container,
forceHydrate,
);
fiberRoot = root._internalRoot;
if (typeof callback === 'function') {
const originalCallback = callback;
callback = function() {
// instance最终指向 children(入参: 如<App/>)生成的dom节点
const instance = getPublicRootInstance(fiberRoot);
originalCallback.call(instance);
};
}
// 2. 更新容器
unbatchedUpdates(() => {
updateContainer(children, fiberRoot, parentComponent, callback);
});
} else {
// root已经初始化, 二次调用render会进入
// 1. 获取ReactDOMRoot对象
fiberRoot = root._internalRoot;
if (typeof callback === 'function') {
const originalCallback = callback;
callback = function() {
const instance = getPublicRootInstance(fiberRoot);
originalCallback.call(instance);
};
}
// 2. 调用更新
updateContainer(children, fiberRoot, parentComponent, callback);
}
return getPublicRootInstance(fiberRoot);
}

继续跟踪legacyCreateRootFromDOMContainer. 最后调用new ReactDOMBlockingRoot(container, LegacyRoot, options);


function legacyCreateRootFromDOMContainer(
container: Container,
forceHydrate: boolean,
): RootType {
const shouldHydrate =
forceHydrate || shouldHydrateDueToLegacyHeuristic(container);
return createLegacyRoot(
container,
shouldHydrate
? {
hydrate: true,
}
: undefined,
);
}

export function createLegacyRoot(
container: Container,
options?: RootOptions,
): RootType {
return new ReactDOMBlockingRoot(container, LegacyRoot, options); // 注意这里的LegacyRoot是固定的, 并不是外界传入的
}

通过以上分析,legacy模式下调用ReactDOM.render有 2 个核心步骤:



  1. 创建ReactDOMBlockingRoot实例(在 Concurrent 模式和 Blocking 模式中详细分析该类), 初始化 react 应用环境.

  2. 调用updateContainer进行更新.


Concurrent 模式和 Blocking 模式


Concurrent模式和Blocking模式从调用方式上直接可以看出



  1. 分别调用ReactDOM.createRootReactDOM.createBlockingRoot创建ReactDOMRootReactDOMBlockingRoot实例

  2. 调用ReactDOMRootReactDOMBlockingRoot实例的render方法


export function createRoot(
container: Container,
options?: RootOptions,
): RootType {
return new ReactDOMRoot(container, options);
}

export function createBlockingRoot(
container: Container,
options?: RootOptions,
): RootType {
return new ReactDOMBlockingRoot(container, BlockingRoot, options); // 注意第2个参数BlockingRoot是固定写死的
}

继续查看ReactDOMRootReactDOMBlockingRoot对象


function ReactDOMRoot(container: Container, options: void | RootOptions) {
// 创建一个fiberRoot对象, 并将其挂载到this._internalRoot之上
this._internalRoot = createRootImpl(container, ConcurrentRoot, options);
}
function ReactDOMBlockingRoot(
container: Container,
tag: RootTag,
options: void | RootOptions,
) {
// 创建一个fiberRoot对象, 并将其挂载到this._internalRoot之上
this._internalRoot = createRootImpl(container, tag, options);
}

ReactDOMRoot.prototype.render = ReactDOMBlockingRoot.prototype.render = function(
children: ReactNodeList,
): void {
const root = this._internalRoot;
// 执行更新
updateContainer(children, root, null, null);
};

ReactDOMRoot.prototype.unmount = ReactDOMBlockingRoot.prototype.unmount = function(): void {
const root = this._internalRoot;
const container = root.containerInfo;
// 执行更新
updateContainer(null, root, null, () => {
unmarkContainerAsRoot(container);
});
};

ReactDOMRootReactDOMBlockingRoot有相同的特性



  1. 调用createRootImpl创建fiberRoot对象, 并将其挂载到this._internalRoot上.

  2. 原型上有renderumount方法, 且内部都会调用updateContainer进行更新.


创建 fiberRoot 对象


无论哪种模式下, 在ReactDOM(Blocking)Root的创建过程中, 都会调用一个相同的函数createRootImpl, 查看后续的函数调用, 最后会创建fiberRoot 对象(在这个过程中, 特别注意RootTag的传递过程):


// 注意: 3种模式下的tag是各不相同(分别是ConcurrentRoot,BlockingRoot,LegacyRoot).
this._internalRoot = createRootImpl(container, tag, options);

function createRootImpl(
container: Container,
tag: RootTag,
options: void | RootOptions,
) {
// ... 省略部分源码(有关hydrate服务端渲染等, 暂时用不上)
// 1. 创建fiberRoot
const root = createContainer(container, tag, hydrate, hydrationCallbacks); // 注意RootTag的传递
// 2. 标记dom对象, 把dom和fiber对象关联起来
markContainerAsRoot(root.current, container);
// ...省略部分无关代码
return root;
}

export function createContainer(
containerInfo: Container,
tag: RootTag,
hydrate: boolean,
hydrationCallbacks: null | SuspenseHydrationCallbacks,
): OpaqueRoot {
// 创建fiberRoot对象
return createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks); // 注意RootTag的传递
}

创建 HostRootFiber 对象


createFiberRoot中, 创建了react应用的首个fiber对象, 称为HostRootFiber(fiber.tag = HostRoot)


export function createFiberRoot(
containerInfo: any,
tag: RootTag,
hydrate: boolean,
hydrationCallbacks: null | SuspenseHydrationCallbacks,
): FiberRoot {
// 创建fiberRoot对象, 注意RootTag的传递
const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);

// 1. 这里创建了`react`应用的首个`fiber`对象, 称为`HostRootFiber`
const uninitializedFiber = createHostRootFiber(tag);
root.current = uninitializedFiber;
uninitializedFiber.stateNode = root;
// 2. 初始化HostRootFiber的updateQueue
initializeUpdateQueue(uninitializedFiber);

return root;
}

在创建HostRootFiber时, 其中fiber.mode属性, 会与 3 种RootTag(ConcurrentRoot,BlockingRoot,LegacyRoot)关联起来.


export function createHostRootFiber(tag: RootTag): Fiber {
let mode;
if (tag === ConcurrentRoot) {
mode = ConcurrentMode | BlockingMode | StrictMode;
} else if (tag === BlockingRoot) {
mode = BlockingMode | StrictMode;
} else {
mode = NoMode;
}
return createFiber(HostRoot, null, null, mode); // 注意这里设置的mode属性是由RootTag决定的
}

注意:fiber树中所节点的mode都会和HostRootFiber.mode一致(新建的 fiber 节点, 其 mode 来源于父节点),所以HostRootFiber.mode非常重要, 它决定了以后整个 fiber 树构建过程.


运行到这里, 3 个对象创建成功, react应用的初始化完毕.


将此刻内存中各个对象的引用情况表示出来:



  1. legacy


process-legacy.png



  1. concurrent


process-concurrent.png



  1. blocking


process-blocking.png


注意:



  1. 3 种模式下,HostRootFiber.mode是不一致的

  2. legacy 下, div#rootReactDOMBlockingRoot之间通过_reactRootContainer关联. 其他模式是没有关联的

  3. 此时reactElement(<App/>)还是独立在外的, 还没有和目前创建的 3 个全局对象关联起来


调用更新入口



  1. legacy
    回到legacyRenderSubtreeIntoContainer函数中有:


// 2. 更新容器
unbatchedUpdates(() => {
updateContainer(children, fiberRoot, parentComponent, callback);
});


  1. concurrent 和 blocking
    ReactDOM(Blocking)Root原型上有render方法


ReactDOMRoot.prototype.render = ReactDOMBlockingRoot.prototype.render = function(
children: ReactNodeList,
): void {
const root = this._internalRoot;
// 执行更新
updateContainer(children, root, null, null);
};

相同点:



  1. 3 种模式在调用更新时都会执行updateContainer. updateContainer函数串联了react-domreact-reconciler, 之后的逻辑进入了react-reconciler包.


不同点:




  1. legacy下的更新会先调用unbatchedUpdates, 更改执行上下文为LegacyUnbatchedContext, 之后调用updateContainer进行更新.




  2. concurrentblocking不会更改执行上下文, 直接调用updateContainer进行更新.




继续跟踪updateContainer函数


export function updateContainer(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
callback: ?Function,
): Lane {
const current = container.current;
// 1. 获取当前时间戳, 计算本次更新的优先级
const eventTime = requestEventTime();
const lane = requestUpdateLane(current);

// 2. 设置fiber.updateQueue
const update = createUpdate(eventTime, lane);
update.payload = { element };
callback = callback === undefined ? null : callback;
if (callback !== null) {
update.callback = callback;
}
enqueueUpdate(current, update);

// 3. 进入reconcier运作流程中的`输入`环节
scheduleUpdateOnFiber(current, lane, eventTime);
return lane;
}

updateContainer函数位于react-reconciler包中, 它串联了react-domreact-reconciler. 此处暂时不深入分析updateContainer函数的具体功能, 需要关注其最后调用了scheduleUpdateOnFiber.


在前文reconciler 运作流程中, 重点分析过scheduleUpdateOnFiber输入阶段的入口函数.


所以到此为止, 通过调用react-dom包的api(如: ReactDOM.render), react内部经过一系列运转, 完成了初始化, 并且进入了reconciler 运作流程的第一个阶段.


思考


可中断渲染


react 中最广为人知的可中断渲染(render 可以中断, 部分生命周期函数有可能执行多次, UNSAFE_componentWillMount,UNSAFE_componentWillReceiveProps)只有在HostRootFiber.mode === ConcurrentRoot | BlockingRoot才会开启. 如果使用的是legacy, 即通过ReactDOM.render(<App/>, dom)这种方式启动时HostRootFiber.mode = NoMode, 这种情况下无论是首次 render 还是后续 update 都只会进入同步工作循环, reconciliation没有机会中断, 所以生命周期函数只会调用一次.


对于可中断渲染的宣传最早来自2017 年 Lin Clark 的演讲. 演讲中阐述了未来 react 会应用 fiber 架构, reconciliation可中断等(13:15 秒). 在v16.1.0中应用了 fiber.


在最新稳定版v17.0.2中, 可中断渲染虽然实现, 但是并没有在稳定版暴露出 api. 只能安装alpha版本才能体验该特性.


但是不少开发人员认为稳定版本的react已经是可中断渲染(其实是有误区的), 大概率也是受到了各类宣传文章的影响. 前端大环境还是比较浮躁的, 在当下, 更需要静下心来学习.


总结


本章节介绍了react应用的 3 种启动方式. 分析了启动后创建了 3 个关键对象, 并绘制了对象在内存中的引用关系. 启动过程最后调用updateContainer进入react-reconciler包,进而调用schedulerUpdateOnFiber函数, 与reconciler运作流程中的输入阶段相衔接.


写在最后


本文属于图解react源码系列中的运行核心板块, 本系列近 20 篇文章,真的是为了搞懂React源码, 进而提升架构和编码能力.


目前图解部分初稿已经全部完成, 将在8月全部更新, 如文章有表述错误, 会在github第一时间修正.


链接:https://juejin.cn/post/6992771308157141022
收起阅读 »

白话聊React为何采用函数式编程的不可变数据

前言 大家好,今天来聊一下React采用函数式编程的理念:不可变数据。 看到标题的你不用担心,你可能在顾虑需要函数式编程的知识,完全不需要,今天我们就0基础聊聊什么是不可变数据?React采用这种方式有什么好处? 例子 React采用函数式编程的不可变数据特性...
继续阅读 »

前言


大家好,今天来聊一下React采用函数式编程的理念:不可变数据。


看到标题的你不用担心,你可能在顾虑需要函数式编程的知识,完全不需要,今天我们就0基础聊聊什么是不可变数据?React采用这种方式有什么好处?


例子


React采用函数式编程的不可变数据特性。


而在React中不可变值的意思就是:始终保持state的原值不变。


不要直接修改state,遇到数组或者对象,采用copy一份出去做改变。


举个简单的例子:


基本类型


错误的做法:


this.state.count++
this.setState({
count:this.state.count
})

正确的做法:


this.setState({
count:this.state.count + 1
})

引用类型


错误的做法:



this.state.obj1.a = 100
this.state.obj2.a = 100
this.setState({
obj1: this.state.obj1,
obj2: this.state.obj2
})

正确的做法:


this.setState({
obj1: Object.assign({}, this.state.obj1, {a: 100}),
obj2: {...this.state.obj2, a: 100}
})

函数式编程不可变值的优势


我们先聊聊函数式编程中不可变值的好处。


减少bug


好比我们用const定义一个变量,如果你要改变这个变量,你需要把变量copy出去修改。
这样的做法,可以让你的代码少非常多的bug


生成简单的状态管理便于维护


因为,程序中的状态是非常不好维护的,特别是在并发的情况下更不好维护。
试想一下:如果你的代码有一个复杂的状态,当以后别人改你代码的时候,是很容易出bug的。


React中采用函数式编程的不可变值


我们再来看看React中采用函数式编程


性能优化


在生命周期 shouldComponentUpdate 中,React会对新旧state进行比较,如果直接修改state去用于其他变量的计算,而实际上state并不需要修改,则会导致怪异的更新以及没必要的更新,因此采用这种方式是非常巧妙,且效率非常的高。


可追踪修改痕迹,便于排错


使用this.setState的方式进行修改state的值,相当于开了一个改变值的口子,所有的修改都会走这样的口子,相比于直接修改,这样的控制力更强,能够有效地记录与追踪每个state的改变,对排查bug十分有帮助。


结尾


关于React函数式编程你有什么观点或者想法,欢迎在评论区告诉我。


链接:https://juejin.cn/post/6992919744165150751

收起阅读 »

这几个关键的数据结构都不会,你也配学react源码???

不知道大家在学习react源码的时候有没有这样的感觉:fiber对象的结构太复杂了,不仅是属性繁多,而且有些属性还是个巨复杂的对象。我在学习hooks的时候,这种感觉尤为强烈。那么,这篇文章就以fiber.memoizedState和fiber.updateQ...
继续阅读 »

不知道大家在学习react源码的时候有没有这样的感觉:fiber对象的结构太复杂了,不仅是属性繁多,而且有些属性还是个巨复杂的对象。我在学习hooks的时候,这种感觉尤为强烈。那么,这篇文章就以fiber.memoizedStatefiber.updateQueue这两个重要属性为例,梳理一下他们的结构和作用,希望对大家阅读源码有帮助。


先来看类组件


首先来看类组件。类组件的fiber.memoizedStatefiber.updateQueue比较简单,memoizedState就是我们定义的state对象,updateQueue的结构如下:


fiber.updateQueue {
baseState,
firstBaseUpdate,
lastBaseUpdate,
shared: {
pending,
},
effects
}

首先,前三个属性baseStatefirstBaseUpdatelastBaseUpdate是和可中断更新相关的。在concurrent mode下,当前的更新可能被更高优先级的更新打断,而baseState就记录了被跳过的update节点之前计算出的statefirstBaseStatelastBaseUpdate构成一条链表,记录因为优先级不足而被跳过的所有更新。shared.pending才是真正记录了所有update对象的链表,这是一条环形链表,shared.pending指向该链表的最后一个update对象,effects则是一个数组,存储了setState的回调函数。关于类组件的state计算原理,可以看我的这篇文章,这里面有更加详细的讲解,本文还是主要介绍数据结构。


看函数组件


了解hooks原理的同学应该都知道,react使用链表来存储函数组件中用到的所有hooks,而这个链表就保存在fiber.memoizedState。这个hooks链表的每一项都是hook对象,hook对象的结构如下


hook {
memoizedState,
baseState,
baseQueue,
queue,
next
}

hooks链表.jpg



没错,fiber.memoizedState指向了hook链表,而每个hook对象还有一个memoizedState对象!!!



不论是哪种hook,都会使用这一种数据结构,而有些hook可能只会用到其中的几个属性。下面,就由简入繁,介绍不同的hook都用到了哪些属性。


useCallback, useMemo, useRef


这三个hook都只使用到了hook.memoizedState这一个属性。


useCallback接受一个需要缓存的函数和依赖数组,而hook.memoizedState则保存了一个数组,数组内放了缓存函数和依赖数组


function mountCallback(callback, deps) {
var hook = mountWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps];
return callback;
}

useMemouseCallback基本一致,只不过useMemo保存了函数的返回值


function mountMemo(nextCreate, deps) {
var hook = mountWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
var nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}

至于useRef就能简单了,保存一个对象即可


function mountRef(initialValue) {
var hook = mountWorkInProgressHook();
var ref = {
current: initialValue
};

{
Object.seal(ref);
}

hook.memoizedState = ref;
return ref;
}

这里使用Object.seal封闭了ref对象,Object.seal就是在不可扩展(Object.preventExtensions)的基础上不可配置(configurable变为false)。


useState


看完了三个简单的hook,现在加大一点难度,看看useState


useState就比较复杂了,用到了hook对象身上的所有属性


hook {
memoizedState: 保存的state,
baseState:和类组件的updateQueue.baseState作用相同,
baseQueue: 和类组件的updateQueue.firstBaseUpdate和lastBaseUpdate作用相同,
queue: {
pending: 保存update对象的环状链表,
dispatch: dispatchAction,useState的第二个返回值,
lastRenderedReducer: 上次更新时用到的reducer,
lastRenderedState: 上次更新的state
}
}

这里需要注意一点,useState中用到了hook.queue这个属性,而hook.queue.pending和类组件的updateQueue.shared.pending比较类似,都是用来保存update对象的。而使用useState时,fiber.updateQueuenull,也就是说和类组件不同,useState并没有用到updateQueue,这一点容易让人困惑。


useEffect


接下来看看我个人认为最复杂的hookuseEffect。说他复杂,是因为useEffect的数据结构很绕,下面一起来看一下


首先,关于hook对象身上的几个属性,useEffect只用到了一个,就是memoizedState,而在memoizedState上保存的又是一个effect对象


hook {
memoized: {
tag: 用于区分是useEffect还是useLayoutEffect,
create: 就是useEffect的回调函数,
destory: useEffect回调的返回值,
deps: 依赖数组,
next: 下一个effect对象,构成环装链表
},
baseState: null,
baseQueue: null,
queue: null
}

此外,useEffect还用到了fiber.updateQueue,这个updateQueue的结构还和类组件的不一样


// 类组件的updateQueue
{
baseState,
firstBaseUpdate,
lastBaseUpdate,
shared: {
pending
},
effects
}

// useEffect的updateQueue
{
lastEffect
}

useEffect不仅将effect对象放在了hook.memoized上,还放在了fiber.updateQueue上,并且都是环形链表


举个🌰


前面说了这么多,可能大家还是比较困惑,下面看个实际的例子


const App = () => {
const [num, setNum] = useState(1)
const handleClick1 = useCallback(() => {
setNum((pre) => pre + 1)
}, [])

const [count, setCount] = useState(1)
const handleClick2 = useCallback(() => {
setCount((pre) => pre + 1)
}, [])

useEffect(() => {
console.log('1st effect', num)
}, [num])

useEffect(() => {
console.log('2nd effect', num)
}, [num])

useLayoutEffect(() => {
console.log('use layout', count)
}, [count])

return (
<div>
<p>{num}</p>
<p>{count}</p>
<button onClick={handleClick1}>click me</button>
<button onClick={handleClick2}>click me</button>
</div>
)
}

上面的例子中,我们使用了两个useState,并且每个事件处理函数都使用useCallback进行了包装,此外还有useEffectuseLayoutEffect,下面看一下



  1. 首先,执行useState,因此在fiber.memoizedState上挂载了一个useStatehook对象


例子1.jpg



  1. 接下来执行到useCallback,在hooks链表中再添加一项


例子2.jpg



  1. 接下来又是useStateuseCallback,因此和前两步一样


例子3.jpg



  1. 之后来到比较复杂的useEffect了,useEffect不仅会添加hook对象到hooks链表中,还会修改updateQueue


例子4.jpg



  1. 后面还是一个useEffect,这一步就比较复杂了


例子5.jpg


可以看到,使用useEffect时,不仅构成了hooks链表,effect对象也构成了一条环形链表



  1. 之后执行了useLayoutEffect


例子6.jpg


最后来总结一下这个例子


例子总结.jpg


链接:https://juejin.cn/post/6993150359317250085
收起阅读 »

react hooks 万字总结

Hooks is what? react-hooks是react16.8以后,react新增的钩子API,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性. Hook是一些可以让你在函数组件里“钩入” React sta...
继续阅读 »

Hooks is what?



  • react-hooks是react16.8以后,react新增的钩子API,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性.

  • Hook是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。


why use Hooks?


类组件的缺点:(来自官网动机




  • 在组件之间复用状态逻辑很难




  • 复杂组件变得难以理解




  • 难以理解的 class




你必须去理解 JavaScript 中 this 的工作方式,这与其他语言存在巨大差异。还不能忘记绑定事件处理器。没有稳定的语法提案,代码非常冗余。


hooks的出现,解决了上面的问题。另外,还有一些其他的优点



  • 增加代码的可复用性,逻辑性,弥补无状态组件没有生命周期,没有数据管理状态state的缺陷

  • react-hooks思想更趋近于函数式编程。用函数声明方式代替class声明方式,虽说class也是es6构造函数语法糖,但是react-hooks写起来函数即是组件,无疑也提高代码的开发效率(无需像class声明组件那样写声明周期,写生命周期render函数等)


Hooks没有破坏性改动



  • 完全可选的。  你无需重写任何已有代码就可以在一些组件中尝试 Hook。但是如果你不想,你不必现在就去学习或使用 Hook。

  • 100% 向后兼容的。  Hook 不包含任何破坏性改动。

  • 现在可用。  Hook 已发布于 v16.8.0。


使用Hooks的规则


1. 只在最顶层使用 Hook,不要在循环,条件或嵌套函数中调用 Hook


确保总是在你的 React 函数的最顶层调用他们。遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。这让 React 能够在多次的 useState 和 useEffect 调用之间保持 hook 状态的正确。


2. 只在 React 函数中调用 Hook


不要在普通的 JavaScript 函数中调用 Hook,你可以:



  • ✅ 在 React 的函数组件中调用 Hook

  • ✅ 在自定义 Hook 中调用其他 Hook


至于为什么会有这些规则,如果你感兴趣,请参考Hook 规则


useState



const [state, setState] = useState(initialState)




  • useState 有一个参数(initialState 可以是一个函数,返回一个值,但一般都不会这么用),该参数可以为任意数据类型,一般用作默认值.

  • useState 返回值为一个数组,数组的第一个参数为我们需要使用的 state,第二个参数为一个改变state的函数(功能和this.setState一样)


来看一个计时器的案例


import React,{useState} from "react";
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
export default Example;


  • 第一行:  引入 React 中的 useState Hook。它让我们在函数组件中存储内部 state。

  • 第三行:  在 Example 组件内部,我们通过调用 useState Hook 声明了一个新的 state 变量。它返回一对值给到我们命名的变量上。我们把变量命名为 count,因为它存储的是点击次数。我们通过传 0 作为 useState 唯一的参数来将其初始化为 0。第二个返回的值本身就是一个函数。它让我们可以更新 count 的值,所以我们叫它 setCount

  • 第七行:  当用户点击按钮后,我们传递一个新的值给 setCount。React 会重新渲染 Example 组件,并把最新的 count 传给它。


使用多个state 变量


 // 声明多个 state 变量
const [age, setAge] = useState(42);
const [fruit, setFruit] = useState('banana');
const [todos, setTodos] = useState([{ text: '学习 Hook' }]);

不必使用多个 state 变量。State 变量可以很好地存储对象和数组,因此,你仍然可以将相关数据分为一组。


更新State


import React,{useState} from "react";
function Example() {
const [count, setCount] = useState(0);
const [person, setPerson] = useState({name:'jimmy',age:22});
return (
<div>
<p>name {person.name} </p>
// 如果新的 state 需要通过使用先前的 state 计算得出,那么可以将回调函数当做参数传递给 setState。
// 该回调函数将接收先前的 state,并返回一个更新后的值。
<button onClick={() => setCount(count=>count+1)}>Click me</button>
<button onClick={() => setPerson({name:'chimmy'})}>Click me</button>
</div>
);
}
export default Example;
复制代码

setPerson更新person时,不像 class 中的 this.setState,更新 state 变量总是替换它而不是合并它。上例中的person为{name:'chimmy'} 而不是{name:'chimmy',age:22}


useEffect


Effect Hook 可以让你在函数组件中执行副作用(数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用)操作



useEffect(fn, array)



useEffect在初次完成渲染之后都会执行一次, 配合第二个参数可以模拟类的一些生命周期。


如果你熟悉 React class 的生命周期函数,你可以把 useEffect Hook 看做 componentDidMount``componentDidUpdate 和 componentWillUnmount 这三个函数的组合。


useEffect 实现componentDidMount


如果第二个参数为空数组,useEffect相当于类组件里面componentDidMount。


import React, { useState, useEffect } from "react";
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("我只会在组件初次挂载完成后执行");
}, []);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
export default Example;

页面渲染完成后,会执行一次useEffect。打印“我只会在组件初次挂载完成后执行”,当点击按钮改变了state,页面重新渲染后,useEffect不会执行。


useEffect 实现componentDidUpdate


如果不传第二个参数,useEffect 会在初次渲染和每次更新时,都会执行。


import React, { useState, useEffect } from "react";
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("我会在初次组件挂载完成后以及重新渲染时执行");
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
export default Example;

初次渲染时,会执行一次useEffect,打印出“我会在初次组件挂载完成后以及重新渲染时执行”。
当点击按钮时,改变了state,页面重新渲染,useEffect都会执行,打印出“我会在初次组件挂载完成后以及重新渲染时执行”。


useEffect 实现componentWillUnmount


effect 返回一个函数,React 将会在执行清除操作时调用它。


useEffect(() => {
console.log("订阅一些事件");
return () => {
console.log("执行清楚操作")
}
},[]);

注意:这里不只是组件销毁时才会打印“执行清楚操作”,每次重新渲染时也都会执行。至于原因,我觉得官网解释的很清楚,请参考 解释: 为什么每次更新的时候都要运行 Effect


控制useEffect的执行


import React, { useState, useEffect } from "react";
function Example() {
const [count, setCount] = useState(0);
const [number, setNumber] = useState(1);
useEffect(() => {
console.log("我只会在cout变化时执行");
}, [count]);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click cout</button>
<button onClick={() => setNumber(count + 1)}>Click number</button>
</div>
);
}
export default Example;

上面的例子,在点击 click cout按钮时,才会打印“我只会在cout变化时执行”。 因为useEffect 的第二个参数的数组里面的依赖是cout,所以,只有cout发生改变时,useEffect 才会执行。如果数组中有多个元素,即使只有一个元素发生变化,React 也会执行 effect。


使用多个 Effect 实现关注点分离


使用 Hook 其中一个目的就是要解决 class 中生命周期函数经常包含不相关的逻辑,但又把相关逻辑分离到了几个不同方法中的问题。


import React, { useState, useEffect } from "react";
function Example() {
useEffect(() => {
// 逻辑一
});
useEffect(() => {
// 逻辑二
});
useEffect(() => {
// 逻辑三
});
return (
<div>
useEffect的使用
</div>
);
}
export default Example;

Hook 允许我们按照代码的用途分离他们,  而不是像生命周期函数那样。React 将按照 effect 声明的顺序依次调用组件中的每一个 effect。


useEffect中使用异步函数


useEffect是不能直接用 async await 语法糖的


/* 错误用法 ,effect不支持直接 async await*/
useEffect(async ()=>{
/* 请求数据 */
const res = await getData()
},[])

useEffect 的回调参数返回的是一个清除副作用的 clean-up 函数。因此无法返回 Promise,更无法使用 async/await


那我们应该如何让useEffect支持async/await呢?


方法一(推荐)


const App = () => {
useEffect(() => {
(async function getDatas() {
await getData();
})();
}, []);
return <div></div>;
};

方法二


  useEffect(() => {
const getDatas = async () => {
const data = await getData();
setData(data);
};
getDatas();
}, []);

useEffect 做了什么


通过使用这个 Hook,你可以告诉 React 组件需要在渲染后执行某些操作。React 会保存你传递的函数(我们将它称之为 “effect”),并且在执行 DOM 更新之后调用它。


为什么在组件内部调用 useEffect


将 useEffect 放在组件内部让我们可以在 effect 中直接访问 count state 变量(或其他 props)。我们不需要特殊的 API 来读取它 —— 它已经保存在函数作用域中。Hook 使用了 JavaScript 的闭包机制,而不用在 JavaScript 已经提供了解决方案的情况下,还引入特定的 React API。


useContext


概念



const value = useContext(MyContext);



接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。即使祖先使用 React.memo 或 shouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。


别忘记 useContext 的参数必须是 context 对象本身



  • 正确:  useContext(MyContext)

  • 错误:  useContext(MyContext.Consumer)

  • 错误:  useContext(MyContext.Provider)


示例


index.js


import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
// 创建两个context
export const UserContext = React.createContext();
export const TokenContext = React.createContext();
ReactDOM.render(
<UserContext.Provider value={{ id: 1, name: "chimmy", age: "20" }}>
<TokenContext.Provider value="我是token">
<App />
</TokenContext.Provider>
</UserContext.Provider>,
document.getElementById("root")
);

app.js


import React, { useContext } from "react";
import { UserContext, TokenContext } from "./index";

function Example() {
let user = useContext(UserContext);
let token = useContext(TokenContext);
console.log("UserContext", user);
console.log("TokenContext", token);
return (
<div>
name:{user?.name},age:{user?.age}
</div>
);
}
export default Example;

打印的值如下


41PXCT[XET_ZJ7]79K@~6JX.png


提示


如果你在接触 Hook 前已经对 context API 比较熟悉,那应该可以理解,useContext(MyContext) 相当于 class 组件中的 static contextType = MyContext 或者 <MyContext.Consumer>


useContext(MyContext) 只是让你能够读取 context 的值以及订阅 context 的变化。你仍然需要在上层组件树中使用 <MyContext.Provider> 来为下层组件提供 context。


useReducer


概念



const [state, dispatch] = useReducer(reducer, initialArg, init);



useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。(如果你熟悉 Redux 的话,就已经知道它如何工作了。)


在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数


注意点



React 会确保 dispatch 函数的标识是稳定的,并且不会在组件重新渲染时改变。这就是为什么可以安全地从 useEffect 或 useCallback 的依赖列表中省略 dispatch



示例


import React, { useReducer } from "react";
export default function Home() {
function reducer(state, action) {
switch (action.type) {
case "increment":
return { ...state, counter: state.counter + 1 };
case "decrement":
return { ...state, counter: state.counter - 1 };
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, { counter: 0 });
return (
<div>
<h2>Home当前计数: {state.counter}</h2>
<button onClick={(e) => dispatch({ type: "increment" })}>+1</button>
<button onClick={(e) => dispatch({ type: "decrement" })}>-1</button>
</div>
);
}

useCallback


概念


const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);

返回一个 [memoized]回调函数。


把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。


示例


import React, { useState } from "react";
// 子组件
function Childs(props) {
console.log("子组件渲染了");
return (
<>
<button onClick={props.onClick}>改标题</button>
<h1>{props.name}</h1>
</>
);
}
const Child = React.memo(Childs);
function App() {
const [title, setTitle] = useState("这是一个 title");
const [subtitle, setSubtitle] = useState("我是一个副标题");
const callback = () => {
setTitle("标题改变了");
};
return (
<div className="App">
<h1>{title}</h1>
<h2>{subtitle}</h2>
<button onClick={() => setSubtitle("副标题改变了")}>改副标题</button>
<Child onClick={callback} name="桃桃" />
</div>
);
}

执行结果如下图
image.png


当我点击改副标题这个 button 之后,副标题会变为「副标题改变了」,并且控制台会再次打印出子组件渲染了,这就证明了子组件重新渲染了,但是子组件没有任何变化,那么这次 Child 组件的重新渲染就是多余的,那么如何避免掉这个多余的渲染呢?


找原因


我们在解决问题的之前,首先要知道这个问题是什么原因导致的?


咱们来分析,一个组件重新重新渲染,一般三种情况:



  1. 要么是组件自己的状态改变

  2. 要么是父组件重新渲染,导致子组件重新渲染,但是父组件的 props 没有改变

  3. 要么是父组件重新渲染,导致子组件重新渲染,但是父组件传递的 props 改变


接下来用排除法查出是什么原因导致的:


第一种很明显就排除了,当点击改副标题 的时候并没有去改变 Child 组件的状态;


第二种情况,我们这个时候用 React.memo 来解决了这个问题,所以这种情况也排除。


那么就是第三种情况了,当父组件重新渲染的时候,传递给子组件的 props 发生了改变,再看传递给 Child 组件的就两个属性,一个是 name,一个是 onClickname 是传递的常量,不会变,变的就是 onClick 了,为什么传递给 onClick 的 callback 函数会发生改变呢?其实在函数式组件里每次重新渲染,函数组件都会重头开始重新执行,那么这两次创建的 callback 函数肯定发生了改变,所以导致了子组件重新渲染。


用useCallback解决问题


const callback = () => {
doSomething(a, b);
}
const memoizedCallback = useCallback(callback, [a, b])

把函数以及依赖项作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,这个 memoizedCallback 只有在依赖项有变化的时候才会更新。


那么只需这样将传给Child组件callback函数的改造一下就OK了


const callback = () => { setTitle("标题改变了"); };
// 通过 useCallback 进行记忆 callback,并将记忆的 callback 传递给 Child
<Child onClick={useCallback(callback, [])} name="桃桃" />

这样我们就可以看到只会在首次渲染的时候打印出子组件渲染了,当点击改副标题和改标题的时候是不会打印子组件渲染了的。


useMemo


概念



const cacheSomething = useMemo(create,deps)




  • create:第一个参数为一个函数,函数的返回值作为缓存值。

  • deps: 第二个参数为一个数组,存放当前 useMemo 的依赖项,在函数组件下一次执行的时候,会对比 deps 依赖项里面的状态,是否有改变,如果有改变重新执行 create ,得到新的缓存值。

  • cacheSomething:返回值,执行 create 的返回值。如果 deps 中有依赖项改变,返回的重新执行 create 产生的值,否则取上一次缓存值。


useMemo原理


useMemo 会记录上一次执行 create 的返回值,并把它绑定在函数组件对应的 fiber 对象上,只要组件不销毁,缓存值就一直存在,但是 deps 中如果有一项改变,就会重新执行 create ,返回值作为新的值记录到 fiber 对象上。


示例


function Child(){
console.log("子组件渲染了")
return <div>Child</div>
}
function APP(){
const [count, setCount] = useState(0);
const userInfo = {
age: count,
name: 'jimmy'
}
return <Child userInfo={userInfo}>
}


当函数组件重新render时,userInfo每次都将是一个新的对象,无论 count 发生改变没,都会导致 Child组件的重新渲染。


而下面的则会在 count 改变后才会返回新的对象。


function Child(){
console.log("子组件渲染了")
return <div>Child</div>
}
function APP(){
const [count, setCount] = useState(0);
const userInfo = useMemo(() => {
return {
name: "jimmy",
age: count
};
}, [count]);
return <Child userInfo={userInfo}>
}

实际上 useMemo 的作用不止于此,根据官方文档内介绍:以把一些昂贵的计算逻辑放到 useMemo 中,只有当依赖值发生改变的时候才去更新。


import React, {useState, useMemo} from 'react';

// 计算和的函数,开销较大
function calcNumber(count) {
console.log("calcNumber重新计算");
let total = 0;
for (let i = 1; i <= count; i++) {
total += i;
}
return total;
}
export default function MemoHookDemo01() {
const [count, setCount] = useState(100000);
const [show, setShow] = useState(true);
const total = useMemo(() => {
return calcNumber(count);
}, [count]);
return (
<div>
<h2>计算数字的和: {total}</h2>
<button onClick={e => setCount(count + 1)}>+1</button>
<button onClick={e => setShow(!show)}>show切换</button>
</div>
)
}

当我们去点击 show切换按钮时,calcNumber这个计算和的函数并不会出现渲染了.只有count 发生改变时,才会出现计算.


useCallback 和 useMemo 总结


简单理解呢 useCallback 与 useMemo 一个缓存的是函数,一个缓存的是函数的返回就结果。useCallback 是来优化子组件的,防止子组件的重复渲染。useMemo 可以优化当前组件也可以优化子组件,优化当前组件主要是通过 memoize 来将一些复杂的计算逻辑进行缓存。当然如果只是进行一些简单的计算也没必要使用 useMemo。


我们可以将 useMemo 的返回值定义为返回一个函数这样就可以变通的实现了 useCallback。useCallback(fn, deps) 相当于 useMemo(() => fn, deps)


useRef



const refContainer = useRef(initialValue);



useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变


useRef 获取dom


useRef,它有一个参数可以作为缓存数据的初始值,返回值可以被dom元素ref标记,可以获取被标记的元素节点.


import React, { useRef } from "react";
function Example() {
const divRef = useRef();
function changeDOM() {
// 获取整个div
console.log("整个div", divRef.current);
// 获取div的class
console.log("div的class", divRef.current.className);
// 获取div自定义属性
console.log("div自定义属性", divRef.current.getAttribute("data-clj"));
}
return (
<div>
<div className="div-class" data-clj="我是div的自定义属性" ref={divRef}>
我是div
</div>
<button onClick={(e) => changeDOM()}>获取DOM</button>
</div>
);
}
export default Example;

1.png


useRef 缓存数据


useRef还有一个很重要的作用就是缓存数据,我们知道usestate ,useReducer 是可以保存当前的数据源的,但是如果它们更新数据源的函数执行必定会带来整个组件从新执行到渲染,如果在函数组件内部声明变量,则下一次更新也会重置,如果我们想要悄悄的保存数据,而又不想触发函数的更新,那么useRef是一个很棒的选择。


下面举一个,每次换成state 上一次值的例子


import React, { useRef, useState, useEffect } from "react";
function Example() {
const [count, setCount] = useState(0);

const numRef = useRef(count);

useEffect(() => {
numRef.current = count;
}, [count]);

return (
<div>
<h2>count上一次的值: {numRef.current}</h2>
<h2>count这一次的值: {count}</h2>
<button onClick={(e) => setCount(count + 10)}>+10</button>
</div>
);
}
export default Example;

当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。所以,上面的例子中虽然numRef.current的值,已经改变了,但是页面上还是显示的上一次的值,重新更新时,才会显示上一次更新的值。


写在最后


如果文章中有什么错误,欢迎指出。


链接:https://juejin.cn/post/6993139082054336548

收起阅读 »

基于环信MQTT消息云,Web客户端快速实现消息收发

仓库地址: https://gitee.com/yoki_ss_admin/task-1-web使用说明:实例化客户端client.connect();var topic = 'topic/chat'; if(clienct.isConnect){ ...
继续阅读 »

基于环信封装的mqtt的Web客户端,实现了基础的获取 token、服务器连接、消息订阅、取消订阅、消息发送、断开连接


仓库地址: https://gitee.com/yoki_ss_admin/task-1-web

使用说明:

  • 文件引用
  • 实例化客户端
  • 服务器连接
client.connect();
  • 订阅
var topic = 'topic/chat';
if(clienct.isConnect){
client.subscribe(topic);
}
  • 取消订阅
var topic = 'topic/chat';
if(clienct.isConnect){
client.unsubscribe(topic);
}
  • 消息发送
var topic = 'topic/chat';
var message = '这是我要发送的消息';
if(clienct.isConnect){
client.sendMessage(topic,message);
}
  • 连接断开
if(clienct.isConnect){
client.discounnect(topic);
}

运行截图

image

项目依赖

收起阅读 »

看完 React 哲学,我悟了

前言 最近测试给我提的的 bug 终于少了很多, 在 codeReview 的时候同事们也很少指出我那个地方写的不对 反而对我整体的文件结构和组件的编写结构及状态的设计提出了更高的要求,不得不说我这代码水平还是有所提高的,表示在稳步提升的过程还有很大的进步空...
继续阅读 »

前言


最近测试给我提的的 bug 终于少了很多, 在 codeReview 的时候同事们也很少指出我那个地方写的不对


反而对我整体的文件结构和组件的编写结构及状态的设计提出了更高的要求,不得不说我这代码水平还是有所提高的,表示在稳步提升的过程还有很大的进步空间


但是当我在看到同事给我说的整个组件如何分离才能提高维护性和复用性,别人在看的时候也能更清晰的知道这部分的逻辑


当时我就好奇,为啥同事能有这种见解,难道只是因为经验比我多,思考比我深入吗?我的直觉告诉我没有这么简单。


如何在看到设计图的时候就想好如何划分这块业务的逻辑,如何设计自己想要的数据结构,我在脑中思索着,忽然我想起初学 React 的时候那个被我撇过一眼就速度滑过的概念 React 哲学


我立马去官网看着概念, 果然那些我以前我以前对于一个组件不知道证明设计结构和状态,这些组件写到后面状态不通,还有我几乎还会在每个子组件都请求一遍数据,对于一些想显示的数据都定义一个 state 来简单粗暴的解决


在写一个模块的时候我几乎是马上就着手去写,往往都是没有任何思考和设计只是想到什么就写什么,只想着赶快把功能学完,以至于写出很多缝缝补补不合理的代码让我踩过很多坑,收获很多 bug ,在看到之前的组件,我的第一想法就是重构。


说了这么多我的血泪史,我们看一下 React 哲学到底是说的什么,它都是如何解决我上述的痛点的,我又因此悟到了什么?


准备阶段


首先在我们写代码之前肯定会有的是会有的一是 PM 的产品设计图,二是后端同学返回的 JSON 数据


image-20210801203507483的副本.png


[
{category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
{category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
{category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
{category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
{category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
{category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];


先理解这样一个简单的产品设计图所包含的需求都有哪些,这是一个展示商品的列表,用户可以在对商品进行关键字搜索,并且通过点击复选框选择是是否展示现货,商品列表包含商品名和价格,商品支持分类显示,其中告罄的商品名为红色显示。


当我们基本了解产品图所表达的需求之后,就可以开始代码编写的第一步了


通过产品图划分组件层级


在一开始不太熟练划分的时候可以在产品设计稿上通过画方框来确定组件和子组件,可以报组件当成一个函数或者对象来看,组件同样遵照单一功能原则,也就是说一个组件只负责一个功能


同时一个好的 JSON 数据模型也应该是和组件一一对应的,组件与数据模型中的某个部分匹配


image-20210801205928730的副本.png


不同的颜色划分成不同的组件,可以分成五部分:



  1. FilterableProductTable (橙色): 是整个示例应用的整体

  2. SearchBar (蓝色): 接受所有的用户输入

  3. ProductTable (绿色): 展示数据内容并根据用户输入筛选结果

  4. ProductCategoryRow (天蓝色): 为每一个产品类别展示标题

  5. ProductRow (红色): 每一行展示一个产品


组件名应该能让人迅速 get 到这个组件的写的是什么(不得不说我之前的组件命名真的太糟糕了,过几天回头看都是一脸懵逼的那种


组件的层级划分:



  • FilterableProductTable

    • SearchBar

    • ProductTable

      • ProductCategoryRow

      • ProductRow






React 构建静态页面


当我们划分好了组件层级之后可以来写代码了,先利用已有数据模型来写一个不包含交互的UI渲染,这是因为UI渲染的代码比较多,交互要考虑的细节比较多,把这两个过程分开写不容易漏掉一些细节,整个思路也比较清晰


通过复用编写的组件,使用 props 来进行数据的传递,父组件把数据进行层层的传递,这也是 React 的一个特点就是单向数据流动,在这个过程中先不使用 state ,因为 state 表示的是会随着时间变化而变化的,所以在交互的过程中使用


构建应用的时候可以使用自上而下或者自上而下的方法,自上而下表示先写层级最高的组件,如FilterableProductTable 组件,这种比较适合简单的应用; 自下而上表示先写层级最低的组件,如 ProductRow 组件,这种方法比较适合大型的应用构建


const PRODUCTS = [
{category: 'Sporting Goods', price: '$49.99', stocked: true, name: 'Football'},
{category: 'Sporting Goods', price: '$9.99', stocked: true, name: 'Baseball'},
{category: 'Sporting Goods', price: '$29.99', stocked: false, name: 'Basketball'},
{category: 'Electronics', price: '$99.99', stocked: true, name: 'iPod Touch'},
{category: 'Electronics', price: '$399.99', stocked: false, name: 'iPhone 5'},
{category: 'Electronics', price: '$199.99', stocked: true, name: 'Nexus 7'}
];

const FilterableProductTable = () => (
<div>
<SearchBar />
<ProductTable products={PRODUCTS} />
</div>
);

const SearchBar = () => (
<form>
<input type="text" placeholder="Search..." />
<p>
<input type="checkbox" /> Only show products in stock
</p>
</form>
);

const ProductTable = ({ products }) => {
const rows = [];
let lastCategory = null;

products.forEach((product) => {
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category}
/>
);
rows.push(<ProductRow product={product} key={product.name} />);
}
lastCategory = product.category;
});

return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
};

const ProductCategoryRow = ({ category }) => (
<tr>
<td colSpan="2">{category}</td>
</tr>
);

const ProductRow = ({ product }) => {
const name = product.stocked ? (
product.name
) : (
<span style={{ color: "red" }}>{product.name}</span>
);

return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
};


确定 state 的最小且完整的集合


当我们一些其他的数据来触发改变基础数据,让UI具有交互结果,在 React 中就可以使用 state 来表示


最小且完整的表示在于我们可以先找到一些会根据时间产生变化的全部数据,再从这些数据中选出最必要的数据作为 state ,其他数据能通过计算得到。


看一下当前应用有哪些数据:



  • 商品的原始数据

  • 用户的搜索数据

  • 复选框是否选中的值

  • 经过筛选后的数据


在确定这些数据能否成为 state 可以先问一下自己这几个问题



  • 数据是否能通过 props 来传递

  • 是否会通过时间而产生改变

  • 是否可以通过其他 stateprops 计算得到


那么最后我们就可以确认,原始数据可以通过 props 传递,用户搜索的数据和复选框的值可以作为 state ,筛选后的数据可以通过原始数据和用户搜索数据以及复选框数据计算得来。所以最后 state 可以是:



  • 用户的搜索数据

  • 复选框是否选中的值


确定 state 放置的位置


当确定了 state 的最小集合之后,接下来就该确定 state 应该放置在哪个组件里


在前面我们知道了 React 是单向的数据流,自上而下的流动,所以我们应把 state 写在共同所有者(也就是需要这些 state 的组件的共同父组件)


我们可以看到 ProductRow 组件需要筛选后的数据, SearchBar 组件需要搜索的数据和复选框的值, 所以就可以把 state 放在它们的共同所有者组件 FilterableProductTable 组件里,再通过 props 来进行 state 的传递


添加反向数据流


当我们要通过层级较低的组件改变层级较高的组件,就需要通过反向数据流的方式


React 中的反向数据流是通过需要高层级组件通过 props 把改变 state 的方法 (回调函数) 传递给层级较低的组件,子组件 state 的改变后的值传给这个回调函数。


在当前应用中如果想要拿到最新的 state 就需要FilterableProductTable 必须将一个能够触发 state 改变的回调函数(callback)传递给 SearchBar。我们可以使用输入框的 onChange 事件来监视用户输入的变化,并通知 FilterableProductTable 传递给 SearchBar 的回调函数。


const FilterableProductTable = () => {
const [filterText, setFilterText] = React.useState("");
const [inStockOnly, setInStockOnly] = React.useState(false);

return (
<div>
<SearchBar
filterText={filterText}
setFilterText={setFilterText}
inStockOnly={inStockOnly}
setInStockOnly={setInStockOnly}
/>
<ProductTable
products={PRODUCTS}
inStockOnly={inStockOnly}
filterText={filterText}
/>
</div>
);
};

const SearchBar = ({
filterText,
setFilterText,
inStockOnly,
setInStockOnly,
}) => {
const handleProductsSearch = (value) => {
setFilterText(value);
};

const handleStockCheck = (value) => {
setInStockOnly(value);
};

return (
<form>
<input
type="text"
placeholder="Search..."
value={filterText}
onChange={handleProductsSearch}
/>
<p>
<input
type="checkbox"
value={inStockOnly}
onChange={handleStockCheck}
/>{" "}
Only show products in stock
</p>
</form>
);
};

const ProductTable = ({ products, inStockOnly, filterText }) => {
const rows = [];
let lastCategory = null;

products.forEach((product) => {
if (product.name.indexOf(filterText) === -1) {
return;
}
if (inStockOnly && !product.stocked) {
return;
}
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category}
/>
);
rows.push(<ProductRow product={product} key={product.name} />);
}
lastCategory = product.category;
});

return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
};

总结


React 哲学并没有对深奥的道理,相反它更倡导我们把代码写得更加简洁清晰,更具有模块化,这一点在写大型的项目尤为重要,在写代码之前就把大致的结构和涉及的数据结构设计好,会减少 Bug 的产生,减少重构的时间,减少维护的成本。


链接:https://juejin.cn/post/6991650159935356935

收起阅读 »

淘宝详情页分发推荐算法总结:用户即时兴趣强化

商品详情页是手淘内流量最大的模块之一,它加载了数十亿级商品的详细信息,是用户整个决策过程必不可少的一环。这个区块不仅要承接用户对当前商品充分感知的诉求,同时也要能肩负起其他来源导流流量的留存,最终尽可能地激活平台内部流量以及外部流量在整个生态中的活跃度。同时,...
继续阅读 »

商品详情页是手淘内流量最大的模块之一,它加载了数十亿级商品的详细信息,是用户整个决策过程必不可少的一环。这个区块不仅要承接用户对当前商品充分感知的诉求,同时也要能肩负起其他来源导流流量的留存,最终尽可能地激活平台内部流量以及外部流量在整个生态中的活跃度。同时,商品详情页也是众多场景乃至平台链接的纽带,用户在平台中的行为轨迹总会在多场景和详情页间不断交替,并在详情页产生进一步的行为决策(加购/购买等)。因而详情页上除了具备承接用户的“了解更多”的诉求,也应同时满足平台“起承转合中间件”的诉求。


详情页内流量具备两个显著的特性:



  1. 流量大,常是用户购买决策环节;

  2. 承接了大量的外部引流。


出于这两个重要特性,同时也出于提升平台黏度,尽可能地提升用户行为的流畅度的产品设计考量,我们在详情页内部设立了一些全网分发场景,并基于这些场景特点进行了一些算法探索。


背景


信息爆炸导致用户对于海量信息的触达寥若晨星,对于有效信息的触达更是凤毛麟角。如果说社交媒体是无声者的发声者,那推荐系统俨然可以看作是海量信息的发声者,同时也是平台用户被曝光信息的制造者。所以我们有责任与义务做到推荐内容的保质与品控,这对于推荐系统是极大的诉求与挑战。当下的推荐系统通过深度挖掘用户行为,对用户进行个性化需求挖掘与实时兴趣捕捉,旨在于帮助用户在海量信息中快速,精准地定位,从而更好的完成智能化服务。


详情页的分发推荐肩负着【服务商家】,【提升用户使用体验】以及【利好平台分发效能】的重要责任。这给我们场景提出了三个方面不同侧重的需求,它们需要被统筹兼顾,以期能够打造出一个更好的流量分发阵地。我们解决这三个需求的方式是开辟同店商品推荐前置的全网分发模块,在极大程度保证商家权益的同时,让用户能够在一个聚焦的页面快速定位海量商品中“猜你喜欢”的商品。详情页内的推荐和公域推荐有一个最大的差异:每个详情页面都是主商品的信息衍生场,推荐内容受到它较强的约束。现有的大多数研究缺乏对具有先验信息的场景的探索:它们只强调用户的个性化兴趣。有一些重要的、直接相关的先验信息被直接忽略。我们观察到,在单个商品/主题唤醒的推荐页面上,用户的点击行为和主商品(唤醒推荐页面的商品/主题)是高度同质的。在这些场景下,用户已经通过主商品给模型传达了一个很聚焦很明确的意图,所以推荐的相关结果不能肆意泛化。但同时,一味的聚集又回降低分发的效能,使得用户在浏览过程中产生疲劳感。因而这些场景的推荐内容,应当遵循“意图明确,适度发散”的策略。当然,因为有主商品信息的加持,我们在模型调优时能够因地制宜地架构推荐策略,做出一些和其他场景相比,更明确更可解释的用户体验,这是我们写这篇文章的初衷。如果对这样的“以品推品”场景想要知道更多的细节,本篇文章将带您一起来看我们的探索问题——“用户即时兴趣强化与延伸”,以及模型解法和线上工程实践。


场景介绍


其中,全网流量分发场景主要包括详情页底部信息流(邻家好货),主图横滑(新增),加购弹层(新增)。这些场景打破了商家私域画地为牢的局面,充分地提升了私域全网分发的能效。当然为了兼顾商家利益,这些场景将分为两个部分(同店内容推荐模块和跨店内容推荐模块)。


image.png


image.png


技术探索


算法问题定义——即时兴趣强化


进入详情页是用户主动发起的行为,因而用户对于当前页面的主商品有着较强的兴趣聚焦。主商品的信息能够帮助我们快速地定位用户的即时兴趣,这对于推荐算法来说是至关重要的。虽然现在有很多方法将行为序列的末位替代即时兴趣,或是使用模型挖掘即时兴趣,但这些方法均是在不确定事件中进行推理,没有详情页天然带有主商品这样的强意图信息。基于此,我们的工作将从推荐技术的不同方面,将这部分信息建模并加以强化,以期使得详情页分发场景能够结合场景特点,尽可能地满足用户的即时需求。


召回


背景


随着深度学习技术在多个领域的普及以及向量检索技术的兴起,一系列基于类似思想的深度学习召回技术相继涌现。Youtube在2016年提出了DNN在推荐系统做召回的思路,它将用户历史行为和用户画像信息相结合,极大地提升了匹配范围的个性化和丰富性。我们的工作基于同组师兄的召回工作《SDM: 基于用户行为序列建模的深度召回》,《User-based Sequential Deep Match》 也是这一思路的一脉相承。SDM能够很好地建模用户兴趣的动态变化,并且能够综合长短期行为在不同维度进行用户表征,从而更好的使用低维向量表达用户和商品,最终借助大规模向量检索技术完成深度召回。SDM上线较base(多路i2i召回merge)ipv指标提升了2.80%。较SDM模型,CIDM模型IPV提升4.69%。在此基础上,为了契合详情页分发场景的特点,我们丰富并挖掘了主商品相关信息,并将其作为即时兴趣对召回模型进行结构改良。


模型——CIDM(Current Intention Reinforce Deep Match )


image.png


为了能够让模型SDM能够将主商品信息catch到并与用户行为产生交互,我们设计了如下的模型结构,其中trigger即为详情页中的主商品,我们从几个方面对它进行表征及强化:



  1. Trigger-Layer:启发于论文1,对主商品显式建模:除SDM中建模用户长、短期偏好之外,引入用户即时偏好层将主商品特征与长短期偏好融合作为用户最终表达;

  2. Trigger-Attention: 即将原模型中使用的self-attention改为由trigger作为目标的target-attention;

  3. Trigger-Lstm:借鉴论文2中的建模思路,我们将lstm的结构中引入了trigger信息,并添加trigger-gate让lstm倾向于记住更多关于主商品的内容;

  4. Trigger-filter-sequence:实验发现,使用主商品的叶子类目,一级类目过滤得到的序列作为原序列的补充进行召回建模,能够增加收益,故在数据源中添加了cate-filter-seq以及cat1-filter-sequece。


其中前两个点都是比较显而易见的,这里就不再赘述,我们将三四两个创新点详细阐述。


论文2中论证了添加时间门能够更好地捕捉用户的短期和长期兴趣,基于这个结论,我们尝试设计一个trigger-gate用于在模型捕获序列特征中引入trigger信息的影响。我们尝试了多种结构变体,比较work的两种方式(如图):



  1. 将trigger信息作为记忆门的一路输入,即通过sigmoid函数后与之前想要更新的信息相乘;

  2. 平行于第一个记忆门,添加一个新的即时兴趣门,其输入为细胞输入以及当前主商品,和记忆门结构一致。


这样的方式能够将主商品的信息保留的更充分。


image.png


第一种方法,仅是对记忆门进行了修改:


image.png


第二种方法,新加了一个即时兴趣门:


image.png


image.png


这两个实验在离线hr指标分别增长+1.07%. 1.37%,最优版本线上指标ipv+1.1%。


出于我们自己的实验结论:"使用主商品的叶子类目和一级类目过滤得到的序列作为原始序列的补充,作为模型输入能够提升预测准度“。这说明,主商品的结构信息是具有明显的效益的,以它为条件能够对序列样本产生正向约束。究其根本,原始序列中一些和当前主商品相关性较小的样本被过滤掉了,这相当于对数据进行去噪处理。沿着这个思路,联想到自编码机的主要应用为数据降噪与特征降维,故考虑采用基于AE结构的模型对序列进行处理,更多的,由于我们是定向去噪(即剔除与主商品不相关的行为),我们使用变分自编码机(VAE),借主商品信息在隐变量空间对序列表达进行约束,以确保隐层能较好抽象序列数据的特点。


变分自编码机是具有对偶结构(包括编码器和解码器)联合训练的系列模型,它借鉴变分推断的思路,在隐变量空间进行个性化定制,比较契合我们即使兴趣建模的需求。首先我们有一批数据样本图片,其似然分布可以表示为图片,最大化其对数似然时后验概率分布图片是不可知的,因而VAEs用自定义分布图片来近似真实的后验概率图片计算,使用KL散度作为两个分布的相似程度的度量。整体的优化函数可以表示为:


image.png


具体推导可以参见论文5。其中第一项作为使假设的后验分布图片和先验分布图片尽量接近,第二项为重构损失,保证自编码结构整体的稳定性。其中,先验分布图片是我们自定义的,这里想要将主商品的信息融入其中,因而我们假设图片,即使用主商品的表示作为高斯分布的均值,采样batch的二阶矩作为高斯分布的方差带入其中。因此,模型的优化函数变为:


image.png


启发于论文3、4, 我们将网络结构设计为如下形式,使用主商品的特征向量作为mu和sigma引入到变分自编码网络中,规范隐空间中序列特征的表达,并将学习得到的序列隐空间变量seq_hid作为用户的强意图序列表达trigger_emb,和长短期偏好融合。


image.png


这实验在离线hr指标增长+2.23%,线上未测试。


效果


较SDM模型,CIDM模型线上效果IPV提升4.69%。


精排


背景


精排模型基于DIN(Deep Interest Networks)进行探索与发展,我们的想法是在序列信息基础之上融入主商品更多的信息。序列信息挖掘和主商品信息强化其实是我们场景两个需求的外化,主商品信息强化能够很好地抓住用户即时意图,满足用户即时的聚焦需求;而序列信息挖掘是基于当前意图的延伸,能够一定程度上对意图进行发散,使推荐结果不会产生过于集中而带来体验疲劳。当然这两方面需要权衡,让模型识别其中“聚”,“散”的时机与程度。在此基础上,我们进行了1、挖掘主商品更多的语义信息;2、强化主商品信息对于序列特征抽取的指引与影响。


精排模型——DTIN(Deep Trigger-based Interest Network)


首先,我们希望能够挖掘主商品更多的语义信息,这一部分,我们将主商品(trigger)相关的特征和待打分商品(candidate)对齐,然后将这部分特征直接拼到模型的wide侧,让模型提升对于主商品表征的敏感度。


其次,由于DIN的motivation是引入注意力机制来更精准的捕获用户的兴趣点,作为比待打分商品更强的用户兴趣点体现,我们设计了一个双attention结构来强化这部分信息。如图所示,首先,将trigger和candidate商品特征concat,传入第一层attention结构中,学得第一层加权向量图片。这部分权值融合了trigger和candidate的信息,它可以被看作基于主商品及待打分商品交叉的用户兴趣提取。然后,仅使用主商品信息作为查询query传入第二层attention结构中,学得第二层加权向量图片,它可以被看作仅基于即时兴趣的延伸兴趣捕获。之后这两个权重向量按位相乘作为序列加权向量。模型结构设计这部分经历了大量的探索实验,如果有兴趣欢迎大家一起来讨论,这里只呈现我们实验中效果最佳版本。


image.png


效果


较DIN模型,DTIN模型IPV提升9.34%, 对应离线实验auc提升4.6%,gauc提升5.8%。


粗排


动机


粗排模型为的是解决推荐系统应用于工业界的特殊问题,在召回集合较大时,精排模型因复杂度太高而无法保证打分效率。因而粗排模型应运而生。由于详情页分发场景需要从全网亿级商品中进行商品召回,且召回阶段使用了多种召回方式的组合(包括i2i, 向量召回等)。这使得召回数量级较大,而且多路召回存在交叉使得匹配特征不在同一尺度上,这给后续的精排模型带来了较大的压力。基于此,我们开发了桥接召回和精排两部分的粗排模块,它的目标是对召回结果进行初筛,不仅需要兼顾效率与精度,也需要具有兼容多尺度召回方式的能力。基于我们的场景特点,在粗排初筛阶段进行了基于主商品的即时意图的建模。


模型——Tri-tower(Triple-tower Preparatory Ranking Framework)


出于粗排模型对于效率的要求不能构建过于复杂的结构,基于双塔粗排模型,我们针对强化即时兴趣的方向新添加了一个主商品塔trigger-tower,该塔和商品塔的特征保持一致,在顶端输出logits后和商品塔做交叉,作为之前双塔模型的补充添加在sigmoid函数的输入中。模型结构如下:


image.png


其中 Trigger net 和 Item net 使用 item 侧更轻量的一些统计类特征,User net也在deep match的基础上对大规模的id类特征进行了筛检。确保粗排模型轻量且服务快速。最终三塔粗排模型较无粗排模型,IPV指标提升3.96%。


总结


总体来看,详情页分发场景的优化思路比较统一,都是对主商品信息进行挖掘,并在模型中将用户历史行为进行关联加强。我们和传统的兴趣挖掘网络相比,附增了一道关口(即时兴趣强化),将那些明确的,和当前最相关的意图保留下来。通过这样的方式,推荐的结果就有一定程度的收敛。同时,多元兴趣在模型中并没有被完全抹去,只是通过attention网络动态调权来影响结果的发散程度,这也确保我们推荐结果一定的个性化和可发散性。


至此已阐述完“用户即时兴趣强化与延伸”课题在私域分发场景三个主要环节:召回-粗排-精排上面的有收益的尝试,当然这个过程也伴随着很多失败的探索,无论是模型优化和工程实践上的阻塞,都给我们带来了丰硕的实践经验。除了这三个主要模型外,我们在策略和其他环节的模型上也都针对该问题进行了优化,这里不再赘述。如果您对细节或者后续的优化方向感兴趣,欢迎与我们联系。


引用



  1. Tang, Jiaxi, et al. "Towards neural mixture recommender for long range dependent user sequences." The World Wide Web Conference. 2019.

  2. Zhu, Yu, et al. "What to Do Next: Modeling User Behaviors by Time-LSTM." IJCAI. Vol. 17. 2017

  3. Liang, Dawen, et al. "Variational autoencoders for collaborative filtering." Proceedings of the 2018 world wide web conference. 2018.

  4. Li, Xiaopeng, and James She. "Collaborative variational autoencoder for recommender systems." Proceedings of the 23rd ACM SIGKDD international conference on knowledge discovery and data mining. 2017.

  5. Zhao, Shengjia, Jiaming Song, and Stefano Ermon. "Towards deeper understanding of variational autoencoding models." arXiv preprint arXiv:1702.08658 (2017).


链接:https://juejin.cn/post/6992169847207493639

收起阅读 »

用three.js写一个3D地球

着色器的入门介绍 Webgl绘制图形是基于着色器(shader)的绘图机制,着色器提供了灵活且强大的绘制二维或三维图形的方法,所有Webgl程序必须使用它。 着色器语言类似于c语言,当我们写webgl程序时,着色器语言以字符串的形式嵌入在javascrip...
继续阅读 »

着色器的入门介绍



Webgl绘制图形是基于着色器(shader)的绘图机制,着色器提供了灵活且强大的绘制二维或三维图形的方法,所有Webgl程序必须使用它。



着色器语言类似于c语言,当我们写webgl程序时,着色器语言以字符串的形式嵌入在javascript语言中。


比如,要在屏幕上绘制一个点,代码如下:


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
margin: 0
}
</style>
</head>
<body>
<canvas id="webgl"></canvas>
</body>
<script>
//将canvas的大小设置为屏幕大小
var canvas = document.getElementById('webgl')
canvas.height = window.innerHeight
canvas.width = window.innerWidth

//获取webgl绘图上下文
var gl = canvas.getContext('webgl')

//将背景色设置为黑色
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.clear(gl.COLOR_BUFFER_BIT)

//顶点着色器代码(字符串形式)
var VSHADER_SOURCE =
`void main () {
gl_Position = vec4(0.5, 0.5, 0.0, 1.0); //点的位置:x: 0.5, y: 0.5, z: 0。齐次坐标
gl_PointSize = 10.0; //点的尺寸,非必须,默认是0
}`

//片元着色器代码(字符串形式)
var FSHADER_SOURCE =
`void main () {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); //点的颜色:四个量分别代表 rgba
}`

//初始化着色器
initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)

//绘制一个点,第一个参数为gl.POINTS
gl.drawArrays(gl.POINTS, 0, 1)

function initShaders(gl, vshader, fshader) {
var program = createProgram(gl, vshader, fshader);
if (!program) {
console.log('Failed to create program');
return false;
}
gl.useProgram(program);
gl.program = program;
return true;
}

function createProgram(gl, vshader, fshader) {
var vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
var fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
if (!vertexShader || !fragmentShader) {
return null;
}
var program = gl.createProgram();
if (!program) {
return null;
}
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linked) {
var error = gl.getProgramInfoLog(program);
console.log('Failed to link program: ' + error);
gl.deleteProgram(program);
gl.deleteShader(fragmentShader);
gl.deleteShader(vertexShader);
return null;
}
return program;
}

function loadShader(gl, type, source) {
// 创建着色器对象
var shader = gl.createShader(type);
if (shader == null) {
console.log('unable to create shader');
return null;
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!compiled) {
var error = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
return null;
}
return shader;
}
</script>
</html>

上面代码在屏幕右上区域绘制了一个点。


image.png


绘制这个点需要三个必要的信息:位置、尺寸和颜色。



  • 顶点着色器指定点的位置和尺寸。(下面的代码中,gl_Positiongl_PointSizegl_FragColor 都是着色器的内置全局变量。)


var VSHADER_SOURCE = 
`void main () {
gl_Position = vec4(0.5, 0.5, 0.0, 1.0); //指定点的位置
gl_PointSize = 10.0; //指定点的尺寸
}`


  • 片元着色器指定点的颜色。


var FSHADER_SOURCE = 
`void main () {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); //指定点的颜色
}`

attribute变量 和 uniform变量


上面的例子中,我们直接在着色器中指定了点的位置、尺寸和颜色。而实际操作中,这些信息基本都是由js传递给着色器。


用于 js代码 和 着色器代码 通信的变量是attribute变量uniform变量


使用哪一种变量取决于需要传递的数据本身,attribute变量用于传递与顶点相关的数据,uniform变量用于传递与顶点无关的数据。


下面的例子中,要绘制的点的坐标将由js传入。


  //顶点着色器
var VSHADER_SOURCE =
`attribute vec4 a_Position; //声明一个attribute变量a_Position,用于接受js传递的顶点位置
void main () {
gl_Position = a_Position; //将a_Position赋值给gl_Position
gl_PointSize = 10.0;
}`

//片元着色器
var FSHADER_SOURCE =
`void main () {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}`

initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)

//js代码中,获取a_Position的存储位置,并向其传递数据
var a_Position = gl.getAttribLocation(gl.program, 'a_Position')
gl.vertexAttrib3f(a_Position, 0.5, 0.5, 0.0)

gl.drawArrays(gl.POINTS, 0, 1)

varying变量


我们从js传给着色器的通常是顶点相关的数据,比如我们要绘制一个三角形,三角形的顶点位置和顶点颜色由js传入。三个顶点的位置可以确定三角形的位置,那么整个三角形的颜色由什么确定呢?


这就需要varying变量出场了。


webgl中的颜色计算:


顶点着色器中,接收js传入的每个顶点的位置和颜色数据。webgl系统会根据顶点的数据,插值计算出,顶点之间区域中,每个片元(可以理解为组成图像的最小渲染点)的颜色值。插值计算由webgl系统自动完成。


计算出的每个片元的颜色值,再传递给 片元着色器片元着色器根据每个片元的颜色值渲染出图像。


顶点着色器片元着色器,传递工作由varying变量完成。


image.png


代码如下。



  • 顶点着色器代码


var VSHADER_SOURCE = 
`attribute vec4 a_Position; //顶点位置
attribute vec4 a_Color; //顶点颜色
varying vec4 v_Color; //根据顶点颜色,计算出三角形中每个片元的颜色值,然后将每个片元的颜色值传递给片元着色器。
void main () {
gl_Position = a_Position;
v_Color = a_Color; // a_Color 赋值给 v_Color
}`


  • 片元着色器代码


var FSHADER_SOURCE = 
`precision mediump float;
varying vec4 v_Color; //每个片元的颜色值
void main () {
gl_FragColor = v_Color;
}`


  • js代码


var verticesColors = new Float32Array([     //顶点位置和颜色
0.0, 0.5, 1.0, 0.0, 0.0, // 第一个点,前两个是坐标(x,y; z默认是0),后三个是颜色
-0.5, -0.5, 0.0, 1.0, 0.0, // 第二个点
0.5, -0.5, 0.0, 0.0, 1.0 // 第三个点
])

//以下是通过缓冲区向顶点着色器传递顶点位置和颜色
var vertexColorBuffer = gl.createBuffer()

gl.bindBuffer(gl.ARRAY_BUFFER, vertexColorBuffer)
gl.bufferData(gl.ARRAY_BUFFER, verticesColors, gl.STATIC_DRAW)

var FSIZE = verticesColors.BYTES_PER_ELEMENT
var a_Position = gl.getAttribLocation(gl.program, 'a_Position')
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 5, 0)
gl.enableVertexAttribArray(a_Position)

var a_Color = gl.getAttribLocation(gl.program, 'a_Color')
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 5, FSIZE * 2)
gl.enableVertexAttribArray(a_Color)

//绘制一个三角形,第一个参数为gl.TRIANGLES
gl.drawArrays(gl.TRIANGLES, 0, 3)

下面最终绘制出来的效果:


image.png


纹理映射的简单理解


在上面的例子中,我们是为每个顶点指定颜色值。


延伸一下,纹理映射是为每个顶点指定纹理坐标,然后webgl系统会根据顶点纹理坐标,插值计算出每个片元的纹理坐标。


然后在片元着色器中,会根据传入的纹理图像,以及每个片元的纹理坐标,取出纹理图像中对应纹理坐标上的颜色值(纹素),作为该片元的颜色值,并进行渲染。


纹理坐标的特点:



  • 纹理图像左下角为原点(0, 0)。

  • 向右为横轴正方向,横轴最大值为 1(图像右边缘)。

  • 向上为纵轴正方向,纵轴最大值为 1(图像上边缘)。


image.png
不管纹理图像的尺寸是多少,纹理坐标的范围都是: x轴:0-1,y轴:0-1


画一个3D地球


使用webgl进行绘制,步骤和API都比较繁琐,所幸我们可以借助three.js


three.js中的ShaderMaterial可以让我们自己定制着色器,直接操作像素。我们只需要理解着色器的基本原理。


开始画地球吧。


基础球体


基础球体的绘制比较简单,用three.js提供的材质就行。关于材质的基础,在 用three.js写一个反光球 有比较详细的介绍。


var loader = new THREE.TextureLoader() 
var group = new THREE.Group()

//创建本体
var geometry = new THREE.SphereGeometry(20,30,30) //创建球形几何体
var earthMaterial = new THREE.MeshPhongMaterial({ //创建材质
map: loader.load( './images/earth.png' ), //基础纹理
specularMap: loader.load('./images/specular.png'), //高光纹理,指定物体表面中哪部分比较闪亮,哪部分相对暗淡
normalMap: loader.load('./images/normal.png'), //法向纹理,创建更加细致的凹凸和褶皱
normalScale: new THREE.Vector2(3, 3)
})
var sphere = new THREE.Mesh(geometry, earthMaterial) //创建基础球体
group.add(sphere)

image.png


流动大气


使用ShaderMaterial自定义着色器。大气的流动,是通过每次在requestAnimationFrame渲染循环中改变纹理坐标实现。为了使流动更加自然,加入噪声。


//顶点着色器
var VSHADER_SOURCE = `
varying vec2 vUv;
void main () {
vUv = uv; //顶点纹理坐标
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );
}
`

//片元着色器
var FSHADER_SOURCE = `
uniform float time; //时间变量
uniform sampler2D fTexture; //大气纹理图像
uniform sampler2D nTexture; //噪声纹理图像
varying vec2 vUv; //片元纹理坐标
void main () {
vec2 newUv= vUv + vec2( 0, 0.02 ) * time; //向量加法,根据时间变量计算新的纹理坐标

//利用噪声随机使纹理坐标随机化
vec4 noiseRGBA = texture2D( nTexture, newUv );
newUv.x += noiseRGBA.r * 0.2;
newUv.y += noiseRGBA.g * 0.2;

gl_FragColor = texture2D( fTexture, newUv ); //提取大气纹理图像的颜色值(纹素)
}
`

var flowTexture = loader.load('./images/flow.png')
flowTexture.wrapS = THREE.RepeatWrapping
flowTexture.wrapT = THREE.RepeatWrapping

var noiseTexture = loader.load('./images/noise.png')
noiseTexture.wrapS = THREE.RepeatWrapping
noiseTexture.wrapT = THREE.RepeatWrapping

//着色器材质
var flowMaterial = new THREE.ShaderMaterial({
uniforms: {
fTexture: {
value: flowTexture,
},
nTexture: {
value: noiseTexture,
},
time: {
value: 0.0
},
},
// 顶点着色器
vertexShader: VSHADER_SOURCE,
// 片元着色器
fragmentShader: FSHADER_SOURCE,
transparent: true
})
var fgeometry = new THREE.SphereGeometry(20.001,30,30) //创建比基础球体略大的球状几何体
var fsphere = new THREE.Mesh(fgeometry, flowMaterial) //创建大气球体
group.add(fsphere)
scene.add( group )

创建了group,基础球体和大气球体,都加入到group,作为一个整体,设置转动和位置,都直接修改group的属性。


var clock = new THREE.Clock()
//渲染循环
var animate = function () {
requestAnimationFrame(animate)
var delta = clock.getDelta()
group.rotation.y -= 0.002 //整体转动
flowMaterial.uniforms.time.value += delta //改变uniforms.time的值,用于片元着色器中的纹理坐标计算
renderer.render(scene, camera)
}

animate()

image.png


光晕


创建光晕用的是精灵(Sprite),精灵是一个总是面朝着摄像机的平面,这里用它来模拟光晕,不管球体怎么转动,都看上去始终处于光晕中。


var ringMaterial = new THREE.SpriteMaterial( {  //创建点精灵材质
map: loader.load('./images/ring.png')
} )
var sprite = new THREE.Sprite( ringMaterial ) //创建精灵,和普通物体的创建不一样
sprite.scale.set(53,53, 1) //设置精灵的尺寸
scene.add( sprite )

最终效果图:


earth-gif-l.gif


链接:https://juejin.cn/post/6992445067344478239

收起阅读 »

这种微前端设计思维听说过吗?

前言:最近有种感觉,好像微前端成为当下前端工程师的标配,从single-spa到qiankun,各种微前端架构解决方案层出不穷。那一夜,我在翻阅github时,留意到一个新的微前端框架,来自京东零售开源的MicroApp,号称无需像上面提到那两个框架一样需要对...
继续阅读 »

前言:最近有种感觉,好像微前端成为当下前端工程师的标配,从single-spa到qiankun,各种微前端架构解决方案层出不穷。那一夜,我在翻阅github时,留意到一个新的微前端框架,来自京东零售开源的MicroApp,号称无需像上面提到那两个框架一样需要对子应用的渲染逻辑调整,甚至还不用修改webpack配置。还有一个成功引起我注意的是:它把web-components的概念给用上了!让我们一探究竟!



1.饭后小菜 - Web Components 🍵


众所周知,Web Components 是一种原生实现可服用web组件的方案,你可以理解为类似在vue、React这类框架下开发的组件。不同的是,基于这个标准下开发的组件可以直接在html下使用,不用依赖其他第三方的库。



换句话说:部分现代浏览器提供的API使我们创建一个可复用的组件而无需依赖任何框架成为一种可能,不会被框架所限制



主要包括以下几个特征:



  • 使用custom elements自定义标签

  • 使用shadow DOM做样式隔离

  • 使用 templates and slots 实现组件拓展 (本期不拓展)


那 Web Components是如何创建一个组件的?我们来看下下面这个demo实践


1.1 实践



针对web components的实践, 我在github上找到一个demo。如下图所示,假设一个页面是由三个不同团队负责独立开发,A团队负责红色区域的整体展示功能,B团队和C团队分别负责蓝色和绿色区域(在红色区域内展示),那他们是怎么实现的?



image.png


我们以绿色区域的功能为示例,来看看demo的代码实例,本质上可以理解为定义一个组件green-recos


carbon (27).png


通过上图,我们来分析这段代码,主要包括以下几点信息:



  • 如何自定义元素?: 通过Api:window.customElements中的defind方法来定义注册好的实例

  • 如何定义一个组件实例?: 通过继承HTMLElement定义一个是实例类

  • 如何与外部通信的?:通过创建一个CustomEvent来自定义一个新的事件,然后通过addEventListener来监听以及element.dispatchEvent() 来分发事件

  • 如何控制组件的生命周期?: 主要是包括这几个生命周期函数,顺序如下 👇



constructor(元素初始化) -> attributeChangedCallback(当元素增加、删除、修改自身属性时,被调用) -> connectedCallback(当元素首次被插入文档DOM时,被调用) -> disconnectedCallback(当 custom element从文档DOM中删除时,被调用)`



拓展:



1.2 关于兼容性



👨‍🎓 啊乐同学:树酱,听说web component兼容性不太好?咋整?



image.png
你可以看上图👆 ,大部分浏览器新版本支持,如果想兼容旧版本,莫慌,可以通过引入polyfill来解决兼容问题 webcomponents/polyfills


你也可以通过坚挺WebComponentsReady这个事件来得知web components是否成功加载


1.3 关于样式冲突


关于样式,上面例子的样式是全局引用的,并没有解决样式冲突的问题,那如果想基于Web Components 开发组件,又担心各组件间存在样式冲突,这个时候你可以使用Shadow DOM来解决,有点类似vue中定义组件中的scoped处理



Shadow DOM: 也称影子DOM,它可以将一个隐藏的、独立的 DOM 附加到一个元素上。如下图MDN官方介绍图所示



image.png


那基于web component如何开发一个挂在#shadow-root的组件?


carbon (28).png


我们可以看到通过上图对比上一节的例子,多了attachShadow的方法使用。它是啥玩意?



官方介绍:通过attachShadow来将一个 shadow root 附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个 mode 属性。当mode为true,则表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM



🌲 扩展阅读:



1.4 注意细节



啊乐同学:树君,那我在vue中可以使用Web Component开发的自定义组件吗?



可以的,但是有一点要注意就是,Vue 组件开发很类似自定义元素,如果我们不做点“手段”处理,vue会把你基于Web Component开发的组件当作本身框架下的组件来看待,so 我们需要配置ignoredElements,下图是vue官网的示例


image.png


如果想了解更多关于Web Component的组件开发,可以看看下面这个开源的组件库



2 Mrcio-app



一不小心绕远了,言归正传,聊聊今日主角:micro-app



使用过qiankun的童鞋知道,我们要在基座集成一个微应用离不开下面👇 这三要素:



  • 在基座注册子应用

  • 需要在子应用定义好生命周期函数

  • 修改微应用的webpack打包方式


虽然改造成本不算特别高,但是能尽量降低对源代码的侵入性不香吗?


Mrcio-app 走的就是极简的路线,只要修改一丢丢代码就可以实现微应用的集成,号称是目前市面上接入微前端成本最低的方案。那它是如何做到的?


2.1 原理


本质上 micro-app 是基于类WebComponent + HTML Entry实现的微前端架构


image.png



官方介绍:通过自定义元素micro-app的生命周期函数connectedCallback监听元素被渲染,加载子应用的html并转换为DOM结构,递归查询所有js和css等静态资源并加载,设置元素隔离,拦截所有动态创建的script、link等标签,提取标签内容。将加载的js经过插件系统处理后放入沙箱中运行,对css资源进行样式隔离,最后将格式化后的元素放入micro-app中,最终将micro-app元素渲染为一个微前端的子应用。在渲染的过程中,会执行开发者绑定的生命周期函数,用于进一步操作。





  • 关于HTML Entry:相信用过qiankun 的童鞋应该都很熟悉,就是加载微应用的入口文件,一方面对微应用的静态资源js、CSS等文件进行fetch,一方面渲染微应用的dom




  • 类WebComponent: 我们在上一节学习web Component中了解到两个特征:CustomElementShadowDom,前者使得我们可以创建自定义标签,后者则促使我们可以创建支持隔离样式和元素隔离的阴影DOM。而首次提及的类WebComponent是个啥玩意?本质上就是通过使用CustomElement结合自定义的ShadowDom实现WebComponent基本一致的功能




换句话说:让微前端下微应用实现真正意义上的组件化


2.2 很赞的机制


micro-app 有这几个机制我觉得很赞:



  • 不用像qiankun一样在每个微应用都预先定义好生命周期函数,如:createdmounted等,而是另辟蹊径,当你在基座集成后,在基座可以直接定义,也可以进行全局监听。如下所示


carbon (29).png


上图的属性配置中name是微应用的名称配置,url是子应用页面地址配置,其他则是各个生命周期函数的定义



  • 资源地址自动补全:我们在基座加载微应用的时候,当微应用涉及图片或其他资源加载时,如果访问路径是相对地址,我们会发现会以基座应用所在域名地址补全静态资源,导致资源加载错误。而micro-app支持将子应用静态资源的相对地址补全为绝对地址,解决了上述的问题


image.png


2.3 实践


2.3.1 demo上手



上手也很简单,以vue2应用为例,具体参考 github文档。这里不做重复陈述



通过官方在线演示vue微应用Demo,我们来看看集成后的效果


image.png


在控制台我们可以看到,基座加载完微应用"vue2",在自定义标签micro-app渲染后就是一个完整子应用Dom,有点类似iframe的感觉,然后该子应用的css样式,都多了一个前缀 micro-app[name=vue2]。这是利用标签的name属性为每个样式添加前缀,将子应用的样式影响禁锢在当前标签区域,避免各个微应用之间的样式冲突。这是micro-app的默认隔离机制



啊乐同学:树酱,他这个元素隔离是怎么实现的?



你听我解释,看下一节源码分析


2.3.2 渲染微应用的过程


渲染微应用的过程主要流程图可以参照官方提供,主要包括以下流程


image.png



  • fetch 子应用HTMl: 获取html,然后转换为dom结构并递归处理每一个子元素,对不同元素做相应的处理 源码链接


目的是为了提取微应用的link和script,绑定style作用域。最后实现将微应用的style挂在micro-app-head中 核心源码如下
carbon (6).png


通过源码的阅读,当我们在微应用的初始化定义的app.scopecss配置时(默认开启),就会调用scopedCSS处理dom
,以此实现绑定微应用的css作用域,让我们看下这个方法的实现 源码链接


我在源码中看到scoped_css主要针对几种cssRule来做区分处理



啊恒同学:树酱,什么是Css Rule?



这是一个有历史的概念了,CSSRule 表示一条 CSS 规则。而一个 CSS 样式表包含了一组表示规则CSSRule对象。 CSSRule 有几种不同的规则类型,你可以在micro-app主要针对以下几种常规的cssRule区分处理



  • CSSRule.STYLE_RULE: 一般的style规则

  • CSSRule.MEDIA_RULE: CSS @media 媒体属性查询的规则

  • CSSRule.SUPPORTS_RULE: CSS @support 可以根据浏览器对CSS特性的支持情况来定义不同的样式的规则


carbon (7).png


最后将转化成功的style内容,append到micro-app-head中



啊恒同学:树酱,你说micro-app隔离元素支持shadowDom ?



是的,如果开启shadowDOM后,上面提到的默认的样式隔离将失效。 且兼容性会比较差


下面是个删减版:关于mircro-app通过Web Component + shadowDOM的实现子应用初始化的定义,具体的源码你可以阅读框架源码中关于micro_app_element的定义 源码链接
carbon (8).png


本质上开启shadowDom后,<micro-app>标签才算真正实现意义上的WebComponent



链接:https://juejin.cn/post/6992500880364797960

收起阅读 »

你可能不知道的动态组件玩法?

○ 背景 知道的大佬请轻锤😂。 这篇是作者在公司做了活动架构升级后,产出的主文的前导篇,考虑到本文相对独立,因此抽离出单独成文。 题目为动态组件,但为了好理解可以叫做远程加载动态组件,后面统一简化称为“远程组件”。 具体是怎么玩呢?别着急,听我慢慢道来,看...
继续阅读 »

○ 背景



知道的大佬请轻锤😂。


这篇是作者在公司做了活动架构升级后,产出的主文的前导篇,考虑到本文相对独立,因此抽离出单独成文。



题目为动态组件,但为了好理解可以叫做远程加载动态组件,后面统一简化称为“远程组件”。


具体是怎么玩呢?别着急,听我慢慢道来,看完后会感慨Vue组件还能这么玩🐶,还会学会一个Stylelint插件,配有DEMO,以及隐藏在最后的彩蛋。


作者曾所在我司广告事业部,广告承载方式是以刮刮卡、大转盘等活动页进行展示,然后用户参与出广告券弹层。


旁白说:远程组件其实在可视化低代码平台也有类似应用,而我们这里也是利用了类似思路实现解耦了活动页和券弹层。继续主题...


image.png


遗留系统早先版本是一个活动就绑定一个弹层,1对1的绑定关系。


image.png


现在的场景是一个活动可能出不同样式的弹层,这得把绑定关系解除。我们需要多对多,就是一个活动页面可以对应多个广告券弹层,也可以一个广告券弹层对应多个活动页面。


我们可以在本地预先写好几个弹层,根据条件选择不同的弹层,可以满足一个活动对多个弹层。


而我们的需求是让活动页面对应无数种弹层,而不是多种,所以不可能把所有弹层都写在本地。因此怎么办呢?


image.png


因此我们要根据所需,然后通过判断所需的弹层,远端返回对应的代码。其实就是我们主题要讲到的远程组件


讲得容易,该怎么做呢?


○ 远程组件核心


Pure版本


如果是Pure JS、CSS组成的弹层,很自然的我们想到,通过动态的插入JS脚本和CSS,就能组成一个弹层。因此把编译好的JS、CSS文件可以存放在远端CDN。


image.png


看上图,我们可以看到弹窗出来之前,浏览器把CSS、JS下载下来了,然后根据既定代码拼装成一个弹层。


// CSS插入
<link rel="stylesheet" href="//yun.xxx.com/xxx.css">

// JS的动态插入

<script type="text/javascript">
var oHead = document.querySelector('.modal-group');
var oScript = document.createElement('script');
oScript.type = "text/javascript";
oScript.src = "//yun.xxx.com/xxx.js";
oHead.appendChild(oScript);
</script>

通过上面可知,JS、CSS方式能实现Pure版本的远程组件,而在Vue环境下能实现吗。如果按照Pure JS、CSS动态插入到Vue活动下,也是可以很粗糙的实现的。


但有没有更优雅的方式呢?


image.png


Vue版本



选型这篇不细讨论了,后续的主篇会讲为什么选择Vue。



上述是遗留系统的方式,如果我们要技术栈迁移到Vue,也需要对远程组件迁移,我们需要改造它。


让我们来回顾下Vue的一些概念。


组件形式


「对象组件」


一个弹窗,其实我们可以通过一个Vue组件表示,我们想把这个组件放到CDN,直接下载这个文件,然后在浏览器环境运行它可行吗?我们来尝试下。


基于Vue官方文档,我们可以把如下的选项对象传入Vue,通过new Vue来创建一个组件。


{
mounted: () => {
console.log('加载')
},
template: "<div v-bind:style=\"{ color: 'red', fontSize: '12' + 'px' }\">Home component</div>"
}

借助于包含编译器的运行时版本,我们可以处理字符串形式的Template。



-- 运行时-编译器-vs-只包含运行时




如果你需要在客户端编译模板 (比如传入一个字符串给Template选项,或挂载到一个元素上并以其 DOM 内部的 HTML 作为模板),就将需要加上编译器,即完整版



似乎找到了新世界的大门。


image.png


我们确实是可以通过这种形式实现Template、Script、CSS了,但对于开发同学,字符串形式的Template、内嵌的CSS,开发体验不友好。


image.png


「单文件组件」


这个时候很自然地想到SFC - 单文件组件。



文件扩展名为.vue的**single-file components (单文件组件)**为以上所有问题提供了解决方法 -- Vue文档。




image.png




但怎么样才能让一个.vue组件从远端下载下来,然后在当前活动Vue环境下运行呢?这是个问题,由于.vue文件浏览器是识别不了的,但.js文件是可以的。


我们先想一下,.vue文件是最终被转换成了什么?


image.png
(图片来源:1.03-vue文件的转换 - 简书


通过转换,实际变成了一个JS对象。所以怎么才能把.vue转换成.js呢?


有两种方式,一种通过运行时转换,我们找到了http-vue-loader。通过Ajax获取内容,解析Template、CSS、Script,输出一个JS对象。


image.png


而考虑到性能和兼容性,我们选择预编译,通过CSS预处理器、HTML模版预编译器。


Vue的官方提供了vue-loader,它会解析文件,提取每个语言块,如有必要会通过其它 loader 处理,最后将他们组装成一个 ES Module,它的默认导出是一个 Vue.js 组件选项的对象。这指的是什么意思呢?官方提供选项对象形式的组件DEMO。


有了理论支持,现在需要考虑下实践啦,用什么编译?


image.png


怎么构建


由于webpack编译后会带了很多关于模块化相关的无用代码,所以一般小型的库会选择rollup,这里我们也选择rollup。


// rollup.config.js
import vue from 'rollup-plugin-vue'
import commonjs from 'rollup-plugin-commonjs'

export default {
input: './skin/SkinDemo.vue',
output: {
format: 'iife',
file: './dist/rollup.js',
name: 'MyComponent'
},
plugins: [
commonjs(),
vue()
]
}

通过rollup-plugin-vue,我们可以把.vue文件转成.js,
rollup编译输出的iife形式js。


image.png


可以看到script、style、template分别被处理成对应的片段,通过整合计算,这些片段会生成一个JS对象,保存为.js文件。下图就是一个组件选项的对象。


image.png


可以通过项目:github.com/fly0o0/remo…,尝试下rollup文件夹下的构建,具体看README说明。


我们已经有了一个 Vue.js 组件选项的对象,怎么去让它挂载到对应的Vue App上呢?


image.png


挂载方式


回想之前通读Vue入门文档,遇到一个动态组件的概念,但当时并不太理解它的使用场景。
image.png


动态组件是可以不固定具体的组件,根据规则替换不同的组件。从文档上看出,支持一个组件的选项对象。


最终实现


首先需要构建.vue文件,然后通过Ajax或动态Script去加载远端JS。由于Ajax会有跨域限制,所以这里我们选择动态Script形式去加载。


而我们刚才使用Rollup导出的方式是把内容挂载在一个全局变量上。那就知道了,通过动态Script插入后,就有一个全局变量MyComponent,把它挂载在动态组件,最终就能把组件显示在页面上了。


具体怎么操作?欠缺哪些步骤,首先我们需要一个加载远程.js组件的函数。


// 加载远程组件js

function cleanup(script){
if (script.parentNode) script.parentNode.removeChild(script)
script.onload = null
script.onerror = null
script = null
}

function scriptLoad(url) {
const target = document.getElementsByTagName('script')[0] || document.head

let script = document.createElement('script')
script.src = url
target.parentNode.insertBefore(script, target)

return new Promise((resolve, reject) => {
script.onload = function () {
resolve()
cleanup(script)
}
script.onerror = function () {
reject(new Error('script load failed'))
cleanup(script)
}
})
}

export default scriptLoad

然后把加载下来的组件,挂载在对应的动态组件上。


<!-- 挂载远程组件 -->

<template>
<component

:is="mode">
</component>
</template>

<script>
import scriptLoad from "./scriptLoad"

export default {
name: "Remote",
data() {
return {
mode: "",
};
},
mounted() {
this.mountCom(this.url)
},
methods: {
async mountCom(url) {
// 下载远程js
await scriptLoad(url)

// 挂载在mode
this.mode = window.MyComponent

// 清除MyComponent
window.MyComponent = null
},
}
}
</script>

基本一个Vue的远程组件就实现了,但发现还存在一个问题。


image.png


全局变量MyComponent需要约定好,但要实现比较好的开发体验来说,应该尽量减少约定。


导出方式


怎么解决呢?由于我们导出是使用的IIFE方式,其实Rollup还支持UMD方式,包含了Common JS和AMD两种方式。


我们通过配置Rollup支持UMD。


// rollup.config.js
import vue from 'rollup-plugin-vue'
import commonjs from 'rollup-plugin-commonjs'

export default {
input: './skin/SkinDemo.vue',
output: {
format: 'umd',
file: './dist/rollup.js',
name: 'MyComponent'
},
plugins: [
commonjs(),
vue()
]
}

可以看到构建完毕后,支持三种方式导出。
image.png


我们可以模拟node环境,命名全局变量exports、module,就可以在module.exports变量上拿到导出的组件。
image.png


具体实现核心代码如下。


<!-- 挂载远程组件 -->

<template>
<component

:is="mode">
</component>
</template>

<script>
import scriptLoad from "./scriptLoad"

export default {
name: "Remote",
data() {
return {
mode: "",
};
},
mounted() {
this.mountCom(this.url)
},
methods: {
async mountCom(url) {
// 模拟node环境
window.module = {}
window.exports = {}

// 下载远程js
await scriptLoad(url)

// 挂载在mode
this.mode = window.module.exports

// 清除
delete window.module
delete window.exports
},
}
}
</script>

终于搞定了Vue版本的远程组件加载的方式。


image.png


接下来得想一想,怎么处理远程组件(弹层)的设计了。


小结


通过使用Vue动态组件实现了远程组件功能,取代了老架构。image.png


可以通过以下地址去尝试一下远程组件弹层,按照项目的README操作一下。会得到以下远程组件弹层。


项目地址:github.com/fly0o0/remo…


image.png


○ 远程组件(弹层)设计



远程组件已达成,这部分主要是对远程弹层组件的一些设计。



对于远程单组件本身来说,只需要根据数据渲染视图,根据用户行为触发业务逻辑,整个代码逻辑是这样的。


需要考虑组件复用、组件通讯、组件封装、样式层级等方向。


首先我们先看看组件复用。


为了方便统一管理和减少冗余代码,我们一般写一些类似的组件会抽取一部分可以公共的组件,例如按钮等。


但远程单组件代码和页面端代码是分离的啊(可以理解为两个webpack入口打包出的产物),我们得想想公共组件需要放在哪里了。


image.png


组件复用


现在可以发现有三种情况,我们利用枚举法尝试想一遍。


打包 📦


公共组件和远程组件打包一起


放在一起肯定不合适,不仅会引起远程组件变大,还不能让其他远程组件复用。往下考虑再看看。


image.png


公共组件单独打包


远程组件、公共组件分别单独打包,这样也是不利的,由于远程组件抽离的公共组件少于5个,而且代码量较少,单独作为一层打包,会多一个后置请求,影响远程组件的第一时间展示。


继续考虑再看看。
image.png
公共组件和页面核心库打包一起


把公共组件和页面核心库打包到一起,避免后面远程组件用到时候再加载,可以提升远程组件的展示速度。
image.png


因此最终敲定选择最后种,把公共组件和页面核心库打包在一起。


如果把远程组件.js和公共组件分开了,那我们该怎么才能使用公共组件啊?😂


image.png


注册 🔑


回顾下Vue官方文档,Vue.component它可以提供注册组件的能力,然后在全局能引用到。我们来试试吧。


公共组件例如按钮、关闭等,需要通过以下途径去注册。


一个按钮组件


// 本地页面端(本地是相较于在远端CDN)

<!-- 按钮组件 -->
<template>
<button type="button" @click="use">
</button>
</template>

<script>
export default {
name: 'Button',
inject: ['couponUseCallback'],
methods: {
use() {
this.couponUseCallback && this.couponUseCallback()
}
}
}
</script>

一个关闭组件


// 本地页面端(本地是相较于在远端CDN)

<!-- 关闭组件 -->
<template>
<span @click="close"></span>
</template>

<script>
export default {
name: "CouponClose",
inject: ["couponCloseCallback"],
methods: {
close() {
this.couponCloseCallback && this.couponCloseCallback();
},
},
};
</script>

<style lang="less" scoped>
.close {
&.gg {
background-image: url("//yun.tuisnake.com/h5-mami/dist/close-gg.png") !important;
background-size: 100% !important;
width: 92px !important;
height: 60px !important;
}
}
</style>

通过Vue.component全局注册公共组件,这样在远程组件中我们就可以直接调用了。


// 本地页面端(本地是相较于在远端CDN)

<script>
Vue.component("CpButton", Button);
Vue.component("CpClose", Close);
</script>

解决了公共组件复用的问题,后面需要考虑下远程组件和页面容器,还有不同类型的远程组件之间的通讯问题。


image.png


组件通讯


可以把页面容器理解为父亲、远程组件理解为儿子,两者存在父子组件跨级双向通讯,这里的父子也包含了爷孙和爷爷孙的情况,因此非props可以支持。那怎么处理?


可以通过在页面核心库中向远程组件 provide 自身,远程组件中 inject 活动实例,实现事件的触发及回调。


那不同类型的远程组件之间怎么办呢,使用Event Bus,可以利用顶层页面实例作为事件中心,利用 on 和 emit 进行沟通,降低不同类别远程组件之间的耦合度。


image.png


组件封装


现在有个组件封装的问题,先看个例子,基本就大概有了解了。


现有3个嵌套组件,如下图。** **现在需要从顶层组件Main.vue给底层组件RealComponent的一个count赋值,然后监听RealComponent的input组件的事件,如果有改变通知Main.vue里的方法。怎么做呢?


image.png


跨层级通信,有多少种方案可以选择?



  1. 我们使用vuex来进行数据管理,对于这个需求过重。

  2. 自定义vue bus事件总线(如上面提到的),无明显依赖关系的消息传递,如果传递组件所需的props不太合适。

  3. 通过props一层一层传递,但需要传递的事件和属性较多,增加维护成本。


而还有一种方式可以通过attrsattrs和listeners,实现跨层级属性和事件“透传”。


主组件


// Main.vue

<template>
<div>
<h2>组件Main 数据项:{{count}}</h2>
<ComponentWrapper @changeCount="changeCount" :count="count">
</ComponentWrapper>
</div>
</template>
<script>
import ComponentWrapper from "./ComponentWrapper";
export default {
data() {
return {
count: 100
};
},
components: {
ComponentWrapper
},
methods: {
changeCount(val) {
console.log('Top count', val)
this.count = val;
}
}
};
</script>

包装用的组件


有的时候我们为了对真实组件进行一些功能增加,这时候就需要用到包装组件(特别是对第三方组件库进行封装的时候)。


// ComponentWrapper.vue

<template>
<div>
<h3>组件包裹层</h3>
<RealComponent v-bind="$attrs" v-on="$listeners"></RealComponent>
</div>
</template>
<script>
import RealComponent from "./RealComponent";
export default {
inheritAttrs: false, // 默认就是true
components: {
RealComponent
}
};
</script>

真正的组件


// RealComponent.vue

<template>
<div>
<h3>真实组件</h3>
<input v-model="myCount" @input="inputHanlder" />
</div>
</template>
<script>
export default {
data() {
return {
myCount: 0
}
},
created() {
this.myCount = this.$attrs.count; // 在组件Main中传递过来的属性
console.info(this.$attrs, this.$listeners);
},
methods: {
inputHanlder() {
console.log('Bottom count', this.myCount)
this.$emit("changeCount", this.myCount); // 在组件Main中传递过来的事件,通过emit调用顶层的事件
// this.$listeners.changeCount(this.myCount) // 或者通过回调的方式
}
}
};
</script>

从例子中回归本文里来,我们要面对的场景是如下这样。


远程组件其实有两层,一层是本地(页面内),一层是远端(CDN)。本地这层只是做封装用的,可以理解为只是包装了一层,没有实际功能。这时候可以理解为本地这一层组件就是包装层,包装层主要做了导入远程组件的功能没办法去除,需要利用上面的特性去传递信息给远程组件。


样式层级


远程组件在本文可以简单理解为远端的弹层组件,公司业务又涉及到不同的弹层类别,每种弹层类别可能会重叠。


约定z-index


因此划分 0~90 为划分十层,后续可根据实际情况增加数值,设定各远程组件容器只能在规定层级内指定 z-index。


// const.js
const FLOOR = {
MAIN: 0, // 主页面容器
COUPON_MODAL: 20, // 广告弹层
OTHER_MODAL: 30, // 其他弹层
ERROR_MODAL: 90,
...
}

设置每种远程组件即弹层的包裹层。



// CouponModalWrapper.vue
<script>
<template>
<div :style="{'z-index': FLOOR.COUPON_MODAL}" @touchmove.prevent>
<slot></slot>
</div>
</template>

// OtherModalWrapper.vue
<template>
<div :style="{'z-index': FLOOR.OTHER_MODAL}" @touchmove.prevent>
<slot></slot>
</div>
</template>

// 这里只是为了表意简单,实际上两个Wrapper.vue可以合并

然后每类别各自引入对应的弹层包裹层。


// 每类别公共组件有一个

// CouponModal2.vue
<template>
<CouponModalWrapper>
...
</CouponModalWrapper>
</template>

// OtherModal2.vue
<template>
<OtherModalWrapper>
...
</OtherModalWrapper>
</template>

通过这种约定的方式,可以避免一些问题,但假如真的有人想捣乱怎么办?


image.png


别着急,有办法的。


借助stylelint


思路是这样的,每类别的远程组件是单独有对应的主文件夹,可以为这个文件夹定义最高和最小可允许的z-index,那该怎么做呢?


不知道大家有使用过自动加-webkit等前缀的插件 - autoprefixer没有,它其实是基于一款postcss工具做的。而我们经常用作css校验格式的工具stylelint也是基于它开发的。


这时候我们想到,能不能通过stylelint的能力,进行约束呢,我们发现找了官方文档并没有我们想要的API。


我们需要自己开发一个stylelint插件,来看看一个基本的stylelint插件的插件。


image.png


stylelint通过stylelint.createPlugin方法,接受一个函数,返回一个函数。


const stylelint = require('stylelint');
const ruleName = 'plugin/z-index-range-plugin';

function rule(options) {
// options传入的配置
return (cssRoot, result) => {
// cssRoot即为postcss对象
};
}

module.exports = stylelint.createPlugin(
ruleName,
rule
);

函数中可以拿到PostCSS对象,可以利用PostCSS对代码进行解析成AST、遍历、修改、AST变代码等操作。


有一些我们可用的概念。



  • rule,选择器,比如.class { z-index: 99 }。

  • decl,属性,比如z-index: 99。


我们需要检查z-index的值,因此需要遍历CSS检查z-index。我们可以调用cssRoot.walkDecls对做遍历:


// 遍历
cssRoot.walkDecls((decl) => {
// 获取属性定义
if (decl) {
// ...
}
});

前置基础知识差不多够用了。


image.png


假如我们要检测一个两个文件夹下的.css文件的z-index是否合乎规矩。


我们设置好两个模块stylelint配置文件下的z-index范围。


这里我们可以看到stylelint配置文件,两个css文件。


├── .stylelintrc.js
├── module1
│ └── index.css
├── module2
│ └── index2.css

stylelint配置文件


// .stylelintrc.js
module.exports = {
"extends": "stylelint-config-standard",
// 自定义插件
"plugins": ["./plugin.js"],
"rules": {
// 自定义插件的规则
"plugin/z-index-range-plugin": {
// 设置的范围,保证各模块不重复
"module1": [100, 199],
"module2": [200, 299]
}
}
}

CSS测试文件


/* module1/index.css */
.classA {
color: red;
width: 99px;
height: 100px;
z-index: 99;
}

/* module2/index.css */
.classB {
color: red;
width: 99px;
height: 100px;
z-index: 200;
}


我们要达到的目的是,运行如下命令,会让module1/index.css报错,说z-index小于预期。


npx stylelint "*/index.css"

于是乎我们完成了如下代码,达成了预期目的。


const stylelint = require('stylelint');
const ruleName = 'plugin/z-index-range-plugin';

function ruleFn(options) {
return function (cssRoot, result) {

cssRoot.walkDecls('z-index', function (decl) {
// 遍历路径
const path = decl.source.input.file
// 提取文件路径里的模块信息
const match = path.match(/module\d/)
// 获取文件夹
const folder = match?.[0]
// 获取z-index的值
const value = Number(decl.value);
// 获取设定的最大值、最小值
const params = {
min: options?.[folder]?.[0],
max: options?.[folder]?.[1],
}

if (params.max && Math.abs(value) > params.max) {
// 调用 stylelint 提供的report方法给出报错提示
stylelint.utils.report({
ruleName,
result,
node: decl,
message: `Expected z-index to have maximum value of ${params.max}.`
});
}

if (params.min && Math.abs(value) < params.min) {
// 调用 stylelint 提供的report方法给出报错提示
stylelint.utils.report({
ruleName,
result,
node: decl,
message: `Expected z-index to have minimum value of ${params.min}.`
});
}
});
};
}

module.exports = stylelint.createPlugin(
ruleName,
ruleFn
);

module.exports.ruleName = ruleName;

可以尝试项目:github.com/fly0o0/styl…,试一试感受一下🐶。


这样基本一个远程弹层的设计就完成了。


但还是遇到了些问题,艰难😂。


image.png


○ 遇到的问题


我们兴冲冲的打算发上线了,结果报错了🐶。报的错是webpackJsonp不是一个function。


不要慌,先吃个瓜镇静镇静。webpackJsonp是做什么的呢?


异步加载的例子


先看下以下例子,通过import的按需异步加载特性加载了test.js,以下例子基于Webpack3构建。


// 异步加载 test.js
import('./test').then((say) => {
say();
});

然后生成了异步加载文件 0.bundle.js。


// 异步加载的文件,0.bundle.js
webpackJsonp(
// 在其它文件中存放着的模块的 ID
[0],
// 本文件所包含的模块
[
// test.js 所对应的模块
(function (module, exports) {
function ;(content) {
console.log('i am test')
}

module.exports = say;
})
]
);

和执行入口文件 bundle.js。


// 执行入口文件,bundle.js
(function (modules) {
/***
* webpackJsonp 用于从异步加载的文件中安装模块。
*
*/
window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
var moduleId, chunkId, i = 0, resolves = [], result;
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
for (moduleId in moreModules) {
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
while (resolves.length) {
resolves.shift()();
}
};

// 模拟 require 语句
function __webpack_require__(moduleId) {
}

/**
* 用于加载被分割出去的,需要异步加载的 Chunk 对应的文件
*/
__webpack_require__.e = function requireEnsure(chunkId) {
// ... 省略代码
return promise;
};

return __webpack_require__(__webpack_require__.s = 0);
})
(
[
// main.js 对应的模块
(function (module, exports, __webpack_require__) {
// 通过 __webpack_require__.e 去异步加载 show.js 对应的 Chunk
__webpack_require__.e(0).then(__webpack_require__.bind(null, 1)).then((show) => {
// 执行 show 函数
show('Webpack');
});
})
]
);

可以看出webpackJsonp的作用是加载异步模块文件。但为什么会报webpackJsonp不是一个函数呢?


开始排查问题


我们开始检查构建出的源码,发现我们的webpackJsonp并不是一个函数,而是一个数组(现已知Webpack4,当时排查时候不知道)。


我们发现异步文件加载的时候确实是变成了数组,通过push去增加一个异步模块到系统里。


// 异步加载的文件

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[/* chunk id */ 0], {
"./src/async.js": (function(module, __webpack_exports__, __webpack_require__) {

//...

}))

且在执行入口文件也发现了webpackJsonp被定义为了数组。


// 执行入口文件,bundle.js中的核心代码  

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;

确实我们构建出的源码的webpackJsonp是一个数组,确实不是一个函数了,感觉找到了一点线索。但为什么会webpackJsonp会函数形式去使用呢?


我们怀疑报错处有问题,开始排查报错处,发现对应的文件确实是用webpackJsonp当作函数去调用的,这是什么情况?🤔️


这时我们注意到报错的都是老架构下的远程组件,是不是在老架构的项目里会有什么蛛丝马迹?


我们开始探索老架构,这时候发现老架构是使用的webpack3,而我们新架构是使用webpack4构建的。难道是这里出了问题?💡


于是我们用webpack3重新构建了下老架构的远程组件,发现webpackJsonp对应的确实是函数,如上一节“异步加载的例子”里所示。


所以定位到了原因,webpack4和webpack3分别构建了新老两种的异步远程组件,webpackJsonp在版本4下是数组,而在版本3下面是函数。


image.png


细心的同学可能已经发现上面的图在之前出现过,webpack4构建的入口文件去加载webpack3构建的异步组件,就出现了章节头出现的webpackJsonp不是函数的错误。


image.png


好好想一想,大概有几个方案。



  1. 批量去修改webpack3构建出来的异步组件中webpackJsonp的命名,然后在容器页面入口里自定义异步加载能力(webpackJsonp功能)的函数。

  2. 重新去用webpack4构建所有遗留的老架构webpack3构建出来的异步组件。

  3. 搜寻是否有官方支持,毕竟这是一个webpack4从webpack3的过来的breack changes。


第一个方案工作量有点大,且怎么保证异步组件和入口文件同步修改完毕呢?
第二个方案工作量也很大,对于所有老架构的异步组件都得更新,且更新后的可靠性堪忧,万一有遗漏。
第三个方案看起来是最靠谱的。


image.png


于是在第三个方案的方向下,开始做了搜寻。


我们通过webpack4源码全局搜寻webpackJsonp,发现了jsonpFunction。通过官方文档找到了jsonpFunction是可以自定义webpack4的webpackJsonp的名称。比如可以改成如下。


output: {
// 自定义名称
jsonpFunction: 'webpack4JsonpIsArray'
},

这样后,webpackJsonp就不是一个数组了,而是未定义了。因此我们需要在我们的公共代码库里提供webpackJsonp函数版本的定义。如异步加载的例子小节所提到的。


// webpackJsonp函数版
!(function (n) {
window.webpackJsonp = function (t, u, i) {
//...
}
}([]))

以此来提供入口页面能加载webpack3构建的异步文件的能力。


○ 演进



我们还对远程组件弹层做了一些演进,由于跟本文关联度不大,只做一些简单介绍。



图片压缩问题


券弹层的券有PNG、JPG、GIF格式,需要更快的展现速度,因此我们做了图片压缩的统一服务。


image.png


gif处理策略:github.com/kornelski/g…
png处理策略:pngquant.org


效率问题


有规律的远程组件,可通过搭建工具处理,因此我们构建了可视化低代码建站工具,有感兴趣的同学留言,我考虑写一篇😂 。


image.png



链接:https://juejin.cn/post/6992483283187531789

收起阅读 »

前端这个工种未来会继续拆分么?

作为前端,你和UI撕过逼么?脑中的场景前端:“上线日期定死了,你什么时候出设计稿?你不出稿子后面开发、测试都得加班!”UI:“快了快了,别催~”前端:“做好的先给我吧,我画静态页面”UI:“快了快了,别催~”前端流泪,后端沉默,终究测试承担了所有......你...
继续阅读 »

作为前端,你和UI撕过逼么?

脑中的场景

前端:“上线日期定死了,你什么时候出设计稿?你不出稿子后面开发、测试都得加班!”

UI:“快了快了,别催~”

前端:“做好的先给我吧,我画静态页面”

UI:“快了快了,别催~”

前端流泪,后端沉默,终究测试承担了所有......

你遇到过这种情况么?

您觉得本质原因是什么?如何才能最高效解决这个问题?

本文会提供一种思路以及可借鉴的产品。

欢迎文末就这个问题讨论

问题原因

现代 Web 开发困境与破局一文中,作者牛岱谈到当前前端与UI的配合模式如下:

图片来自“现代 Web 开发困境与破局”

UI在设计软件上完成设计逻辑、绘制页面样式,交付给前端。

前端根据UI绘制的样式重现用CSS+HTML在网页中再绘制一遍样式,绘制完毕后再添加功能逻辑。

为什么UI用设计软件绘制的页面样式,前端还需要重复绘制一次?仅仅因为UI用设计软件,而前端需要编程么?

所以,理想的分工应该如下:

图片来自“现代 Web 开发困境与破局”

UI完成设计逻辑与页面样式(通过设计软件),软件根据规范生成前端可用的静态页面代码,前端基于生成的代码编写功能逻辑。

大白话讲就是:

前端不用画静态页了

虽然这套流程有诸多难点需要解决,比如:

  • 对于UI来说,页面是一张张图层,对于前端则是一个个组件,怎么对齐这两者差异
  • 需要UI了解基本的页面布局(浮动、flex、绝对定位...),才能生成符合响应式规范的静态页

但是,瑕不掩瑜,如果能跑通这套流程,开发效率将极大提升。

mitosis就是这方面的一次大胆尝试。

一次大胆尝试

BuilderIO是一家低代码平台,主做拖拽生成页面。mitosis的作者是BuilderIOCEO

用一张图概括mitosis的定位:

左起第一排分别是:sketchFigmaBuilderIO,前两者是知名设计软件,后者是低代码平台。

UI使用这些软件完成页面设计,经由插件输出到mitosis后,mitosis能将其输出成多种知名前端框架代码。

设计图一步到位变成前端框架代码,前端就不用画静态页了。

他是怎么做到的?

现代前端框架都是以组件作为逻辑、视图的分割单元。而组件是可以被描述的。

比如ReactFiberVueVNode,都是描述组件信息的节点类型。

mitosis将设计图转化为框架无关的JSON,类似这样:

{
"@type": "@builder.io/mitosis/component",
"state": {
"name": "Steve"
},
"nodes": [
{
"@type": "@builder.io/mitosis/node",
"name": "div",
"children": [
{
"@type": "@builder.io/mitosis/node",
"bindings": {
"value": "state.name",
"onChange": "state.name = event.target.value"
}
}
]
}
]
}


这段JSON描述的是一个component类型(即组件),其包含状态namenodes代表组件对应的视图。

如果输出目标是React,那么代码如下:

export function MyComponent() {
const [name, updateName] = useState('Steve');

return (
<div>
<input
value={name}
onChange={(e) => updateName(e.target.value)}
/>
div>
);
}


小小心机

如果你仔细看这张图会发现,mitosis还能反向输出到设计软件。

是的,mitosis本身也是个框架。有意思的是,他更像是个前端框架缝合怪

他采用了:

  • ReactHooks语法
  • Vue的响应式更新
  • Solid.js的静态JSX
  • Svelte的预编译技术
  • Angular的规范

上面的代码例子,如果用mitosis语法写:

export function MyComponent() {
const state = useState({
name: 'Steve',
});

return (
<div>
<input
value={state.name}
onChange={(e) => (state.name = e.target.value)}
/>
div>
);
}

未曾设想的道路?

我们在开篇谈到阻碍前端直接使用设计软件生成静态代码的两个痛点:

  • 对于UI来说,页面是一张张图层,对于前端则是一个个组件,怎么对齐这两者差异
  • 需要UI了解基本的页面布局(浮动、flex、绝对定位...),才能生成复合响应式规范的静态页

我们设想一下,当使用mitosis开启一个新项目,流程如下:

  1. 由懂设计的前端基于mitosis开发初始代码
  2. 代码输出为设计稿
  3. 专业UI基于设计稿(符合组件规范、响应式规范)润色
  4. 设计稿经由mitosis输出为任意前端框架代码
  5. 前端基于框架代码开发

这样,就解决了以上痛点。

总结

在项目开发过程中,前端需要与后端配合。久而久之,一部分前端同学涉足接口转发的中间层,成为业务+Node工程师。

同样,前端也需要与UI配合,会不会如上文所设想,未来会出现一批UI+前端工程师呢?

收起阅读 »

【Web动画】科技感十足的暗黑字符雨动画

本文将使用纯 CSS,带大家一步一步实现一个这样的科幻字符跳动背景动画。类似于这样的字符雨动画: 或者是类似于这样的: 运用在一些类似科技主题的背景之上,非常的添彩。 文字的竖排 首先第一步,就是需要实现文字的竖向排列: 这一步非常的简单,可能方法也很多...
继续阅读 »

本文将使用纯 CSS,带大家一步一步实现一个这样的科幻字符跳动背景动画。类似于这样的字符雨动画:


Digital Char Rain Animation


或者是类似于这样的:


CodePen Home<br />
Matrix digital rain (animated version) By yuanchuan


运用在一些类似科技主题的背景之上,非常的添彩。


文字的竖排


首先第一步,就是需要实现文字的竖向排列:



这一步非常的简单,可能方法也很多,这里我简单罗列一下:



  1. 使用控制文本排列的属性 writing-mode 进行控制,可以通过 writing-mode: vertical-lr 等将文字进行竖向排列,但是对于数字和英文,将会旋转 90° 展示:


<p>1234567890ABC</p>
<p>中文或其他字符ォヶ</p>

p {
writing-mode: vertical-lr;
}


当然这种情况下,英文字符的展示不太满足我们的需求。



  1. 控制容器的宽度,控制每行只能展示 1 个中文字符。


这个方法算是最简单便捷的方法了,但是由于英文的特殊性,要让连续的长字符串自然的换行,我们还需要配合 word-break: break-all


p {
width: 12px;
font-size: 10px;
word-break: break-all;
}

效果如下,满足需求:



使用 CSS 实现随机字符串的选取


为了让我们的效果更加自然。每一行的字符的选取最好是随机的。


但是要让 CSS 实现随机生成每一行的字符可太难了。所以这里我们请出 CSS 预处理器 SASS/LESS 。


而且由于不太可能利用 CSS 给单个标签内,譬如 <p> 标签插入字符,所以我们把标签内的字符展示,放在每个 <p> 元素的伪元素 ::beforecontent 当中。


我们可以提前设置好一组字符串,然后利用 SASS function 随机生成每一次元素内的 content,伪代码如下:


<div>
<p></p>
<p></p>
<p></p>
</div>

$str: 'ぁぃぅぇぉかきくけこんさしすせそた◁▣▤▥▦▧♂♀♥☻►◄▧▨♦ちつってとゐなにぬねのはひふへほゑまみむめもゃゅょゎをァィゥヴェォカヵキクケヶコサシスセソタチツッテトヰンナニヌネノハヒフヘホヱマミムメモャュョヮヲㄅㄉㄓㄚㄞㄢㄦㄆㄊㄍㄐㄔㄗㄧㄛㄟㄣㄇㄋㄎㄑㄕㄘㄨㄜㄠㄤㄈㄏㄒㄖㄙㄩㄝㄡㄥabcdefghigklmnopqrstuvwxyz123456789%@#$<>^&*_+';
$length: str-length($str);

@function randomChar() {
$r: random($length);
@return str-slice($str, $r, $r);
}

@function randomChars($number) {
$value: '';

@if $number > 0 {
@for $i from 1 through $number {
$value: $value + randomChar();
}
}
@return $value;
}

p:nth-child(1)::before {
content: randomChars(25);
}
p:nth-child(2)::before {
content: randomChars(25);
}
p:nth-child(3)::before {
content: randomChars(25);
}

简单解释下上面的代码:



  1. $str 定义了一串随机字符串,$length 表示字符串的长度

  2. randomChar() 中利用了 SASS 的 random() 方法,每次随机选取一个 0 - $length 的整形数,记为 $r,再利用 SASS 的 str-slice 方法,每次从 $str 中选取一个下标为 $r 的随机字符

  3. randomChars() 就是循环调用 randomChar() 方法,从 $str 中随机生成一串字符串,长度为传进去的参数 $number


这样,每一列的字符,每次都是不一样的:




当然,上述的方法我认为不是最好的,CSS 的伪元素的 content 是支持字符编码的,譬如 content: '\3066'; 会被渲染成字符 ,这样,通过设定字符区间,配合 SASS function 可以更好的生成随机字符,但是我尝试了非常久,SASS function 生成的最终产物会在 \3066 这样的数字间添加上空格,无法最终通过字符编码转换成字符,最终放弃...



使用 CSS 实现打字效果


OK,继续,接下来我们要使用 CSS 实现打字效果,就是让字符一个一个的出现,像是这样:


纯 CSS 实现文字输入效果


这里借助了 animation 的 steps 的特性实现,也就是逐帧动画。


从左向右和从上向下原理是一样的,以从左向右为例,假设我们有 26 个英文字符,我们已知 26 个英文字符组成的字符串的长度,那么我们只需要设定一个动画,让它的宽度变化从 0 - 100% 经历 26 帧即可,配合 overflow: hidden,steps 的每一帧即可展出一个字符。


当然,这里需要利用一些小技巧,我们如何通过字符的数量知道字符串的长度呢?


划重点:通过等宽字体的特性,配合 CSS 中的 ch 单位



如果不了解什么是等宽字体族,可以看看我的这篇文章 -- 《你该知道的字体 font-family》



CSS 中,ch 单位表示数字 “0” 的宽度。如果字体恰巧又是等宽字体,即每个字符的宽度是一样的,此时 ch 就能变成每个英文字符的宽度,那么 26ch 其实也就是整个字符串的长度。


利用这个特性,配合 animation 的 steps,我们可以轻松的利用 CSS 实现打字动画效果:


<h1>Pure CSS Typing animation.</h1>

h1 {
font-family: monospace;
width: 26ch;
white-space: nowrap;
overflow: hidden;
animation: typing 3s steps(26, end);
}

@keyframes typing {
0{
width: 0;
}
100% {
width: 26ch;
}
}

就可以得到如下结果啦:


纯 CSS 实现文字输入效果


完整的代码你可以戳这里:


CodePen Demo -- 纯 CSS 实现文字输入效果


改造成竖向打字效果


接下来,我们就运用上述技巧,改造一下。将一个横向的打字效果改造成竖向的打字效果。


核心的伪代码如下:


<div>
<p></p>
<p></p>
<p></p>
</div>

$str: 'ぁぃぅぇぉかきくけこんさしすせそた◁▣▤▥▦▧♂♀♥☻►◄▧▨♦ちつってとゐなにぬねのはひふへほゑまみむめもゃゅょゎをァィゥヴェォカヵキクケヶコサシスセソタチツッテトヰンナニヌネノハヒフヘホヱマミムメモャュョヮヲㄅㄉㄓㄚㄞㄢㄦㄆㄊㄍㄐㄔㄗㄧㄛㄟㄣㄇㄋㄎㄑㄕㄘㄨㄜㄠㄤㄈㄏㄒㄖㄙㄩㄝㄡㄥabcdefghigklmnopqrstuvwxyz123456789%@#$<>^&*_+';
$length: str-length($str);

@function randomChar() {
$r: random($length);
@return str-slice($str, $r, $r);
}

@function randomChars($number) {
$value: '';

@if $number > 0 {
@for $i from 1 through $number {
$value: $value + randomChar();
}
}
@return $value;
}

p {
width: 12px;
font-size: 10px;
word-break: break-all;
}

p::before {
content: randomChars(20);
color: #fff;
animation: typing 4s steps(20, end) infinite;
}

@keyframes typing {
0% {
height: 0;
}
25% {
height: 100%;
}
100% {
height: 100%;
}
}

这样,我们就实现了竖向的打字效果:



当然,这样看上去比较整齐划一,缺少了一定的随机,也就缺少了一定的美感。


基于此,我们进行 2 点改造:



  1. 基于动画的时长 animation-time、和动画的延迟 animation-delay,增加一定幅度内的随机

  2. 在每次动画的末尾或者过程中,重新替换伪元素的 content,也就是重新生成一份 content


可以借助 SASS 非常轻松的实现这一点,核心的 SASS 代码如下:


$n: 3;
$animationTime: 3;
$perColumnNums: 20;

@for $i from 0 through $n {
$content: randomChars($perColumnNums);
$contentNext: randomChars($perColumnNums);
$delay: random($n);
$randomAnimationTine: #{$animationTime + random(20) / 10 - 1}s;

p:nth-child(#{$i})::before {
content: $content;
color: #fff;
animation: typing-#{$i} $randomAnimationTine steps(20, end) #{$delay * 0.1s * -1} infinite;
}

@keyframes typing-#{$i} {
0% {
height: 0;
}
25% {
height: 100%;
}
100% {
height: 100%;
content: $contentNext;
}
}
}

看看效果,已经有不错的改观:



当然,上述由横向打字转变为竖向打字效果其实是有一些不一样的。在现有的竖向排列规则下,无法通过 ch 配合字符数拿到实际的竖向高度。所以这里有一定的取舍,实际放慢动画来看,没个字的现出不一定是完整的。


当然,在快速的动画效果下几乎是察觉不到的。


增加光影与透明度变化


最后一步,就是增加光影及透明度的变化。


最佳的效果是要让每个新出现的字符保持亮度最大,同时已经出现过的字符亮度慢慢减弱。


但是由于这里我们无法精细操控每一个字符,只能操控每一行字符,所以在实现方式上必须另辟蹊径。


最终的方式是借用了另外一个伪元素进行同步的遮罩以实现最终的效果。下面我们就来一步一步看看过程。


给文字增添亮色及高光


第一步就是给文字增添亮色及高光,这点非常容易,就是选取一个黑色底色下的亮色,并且借助 text-shadow 让文字发光。


p::before {
color: rgb(179, 255, 199);
text-shadow: 0 0 1px #fff, 0 0 2px #fff, 0 0 5px currentColor, 0 0 10px currentColor;
}

看看效果,左边是白色字符,中间是改变字符颜色,右边是改变了字体颜色并且添加了字体阴影的效果:



给文字添加同步遮罩


接下来,就是在文字动画的行进过程中,同步添加一个黑色到透明的遮罩,尽量还原让每个新出现的字符保持亮度最大,同时已经出现过的字符亮度慢慢减弱。


这个效果的示意图大概是这样的,这里我将文字层和遮罩层分开,并且底色从黑色改为白色,方便理解:


蒙层遮罩原理图


大概的遮罩的层的伪代码如下,用到了元素的另外一个伪元素:


p::after {
content: '';
background: linear-gradient(rgba(0, 0, 0, .9), transparent 75%, transparent);
background-size: 100% 220%;
background-repeat: no-repeat;
animation: mask 4s infinite linear;
}

@keyframes mask {
0% {
background-position: 0 220%;
}
30% {
background-position: 0 0%;
}
100% {
background-position: 0 0%;
}
}

好,合在一起的最终效果大概就是这样:



通过调整 @keyframes mask 的一些参数,可以得到不一样的字符渐隐效果,需要一定的调试。


完整代码及效果


OK,拆解了一下主要的步骤,最后上一下完整代码,应用了 Pug 模板引擎和 SASS 语法。


完整代码加起来不过 100 行。


.g-container
-for(var i=0; i<50; i++)
p

@import url('https://fonts.googleapis.com/css2?family=Inconsolata:wght@200&display=swap');

$str: 'ぁぃぅぇぉかきくけこんさしすせそた◁▣▤▥▦▧♂♀♥☻►◄▧▨♦ちつってとゐなにぬねのはひふへほゑまみむめもゃゅょゎをァィゥヴェォカヵキクケヶコサシスセソタチツッテトヰンナニヌネノハヒフヘホヱマミムメモャュョヮヲㄅㄉㄓㄚㄞㄢㄦㄆㄊㄍㄐㄔㄗㄧㄛㄟㄣㄇㄋㄎㄑㄕㄘㄨㄜㄠㄤㄈㄏㄒㄖㄙㄩㄝㄡㄥabcdefghigklmnopqrstuvwxyz123456789%@#$<>^&*_+';
$length: str-length($str);
$n: 50;
$animationTime: 4;
$perColumnNums: 25;

@function randomChar() {
$r: random($length);
@return str-slice($str, $r, $r);
}

@function randomChars($number) {
$value: '';

@if $number > 0 {
@for $i from 1 through $number {
$value: $value + randomChar();
}
}
@return $value;
}

body, html {
width: 100%;
height: 100%;
background: #000;
display: flex;
overflow: hidden;
}

.g-container {
width: 100vw;
display: flex;
justify-content: space-between;
flex-wrap: nowrap;
flex-direction: row;
font-family: 'Inconsolata', monospace, sans-serif;
}

p {
position: relative;
width: 5vh;
height: 100vh;
text-align: center;
font-size: 5vh;
word-break: break-all;
white-space: pre-wrap;

&::before,
&::after {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
overflow: hidden;
}
}

@for $i from 0 through $n {
$content: randomChars($perColumnNums);
$contentNext: randomChars($perColumnNums);
$delay: random($n);
$randomAnimationTine: #{$animationTime + random(20) / 10 - 1}s;

p:nth-child(#{$i})::before {
content: $content;
color: rgb(179, 255, 199);
text-shadow: 0 0 1px #fff, 0 0 2px #fff, 0 0 5px currentColor, 0 0 10px currentColor;
animation: typing-#{$i} $randomAnimationTine steps(20, end) #{$delay * 0.1s * -1} infinite;
z-index: 1;
}

p:nth-child(#{$i})::after {
$alpha: random(40) / 100 + 0.6;
content: '';
background: linear-gradient(rgba(0, 0, 0, $alpha), rgba(0, 0, 0, $alpha), rgba(0, 0, 0, $alpha), transparent 75%, transparent);
background-size: 100% 220%;
background-repeat: no-repeat;
animation: mask $randomAnimationTine infinite #{($delay - 2) * 0.1s * -1} linear;
z-index: 2;
}

@keyframes typing-#{$i} {
0% {
height: 0;
}
25% {
height: 100%;
}
100% {
height: 100%;
content: $contentNext;
}
}
}

@keyframes mask{
0% {
background-position: 0 220%;
}
30% {
background-position: 0 0%;
}
100% {
background-position: 0 0%;
}
}

最终效果也就是题图所示:


Digital Char Rain Animation


完整的代码及演示效果你可以戳这里:


CodePen Demo -- Digital Char Rain Animation



链接:https://juejin.cn/post/6991657194282450951
收起阅读 »

前端button组件之涟漪效果

前言 在前端项目中,我们常常会使用到button组件进行事件的触发,而一些项目为了更好的交互效果,加入了一系列的动画,例如:脉冲、果冻、涟漪、滑箱等特效。 今天我们来讲讲如何使用HTML CSS和JavaScript来实现涟漪效果,我们先看下成品: 看完是...
继续阅读 »

前言


在前端项目中,我们常常会使用到button组件进行事件的触发,而一些项目为了更好的交互效果,加入了一系列的动画,例如:脉冲、果冻、涟漪、滑箱等特效。


今天我们来讲讲如何使用HTML CSSJavaScript来实现涟漪效果,我们先看下成品:


1.gif


5.png


看完是不是也想给自己项目整一个这样子的效果😎😎


原理


如图,我们需要两个元素来实现这个涟漪效果,当button被点击时,在button元素中放置一个元素,执行一个绽开动画效果,执行完毕后把buttion里的元素移除。


2.png


用码实现


码出基本样式

先创建一对div标签,作为一个基础按钮元素。后面我们将这对div称之为按钮。


<div id="btn" class="button">Click me</div>

为按钮添加基本样式,这里需要给按钮设定position:relative,后续我们涟漪效果是通过绝对定位来实现的。


.button {
   -webkit-user-select: none;
   -moz-user-select: none;
   -ms-user-select: none;
   user-select: none;
   position: relative;
   display: inline-block;
   color: #fff;
   padding: 14px 40px;
   background: linear-gradient(90deg, #0bc7f1, #c471ed);
   border-radius: 45px;
   margin: 0 15px;
   font-size: 24px;
   font-weight: 400;
   text-decoration: none;
   overflow: hidden;
   box-shadow: 1px 1px 3px #7459e9;
}

3.png


当样式写完之后我们按钮的样式就跟效果图上的按钮一模一样了,由于我们JavaScript部分还没有写以及实现涟漪效果还没有实现,此时我们点击按钮是没有涟漪效果的,接下来我们要就添加涟漪效果了。


👇 👇 👇 继续往下看 👇 👇 👇


码出链漪

给按钮添加一个涟漪效果,在按钮div中添加一个span标签,并绑定一个overlay


<div id="btn" class="button">
  Click me
   <span class="overlay"></span>
</div>

这个span标签是我们要实现涟漪效果的元素,给元素设置绝对定位,让元素脱离文件流,不为该元素预留出空间。默认我们定义在top:0left:0,再通过transform属性将元素偏移居中对齐。透明度设置0.5,绑定一个blink帧动画函数。


.overlay {
   position: absolute;
   height: 400px;
   width: 400px;
   background-color: #fff;
   top: 0;
   left: 0;
   transform: translate(-50%, -50%);
   border-radius: 50%;
   opacity: .5;
   animation: blink .5s linear infinite;
}

添加一个帧动画,命名为blink,将span元素的宽度,高度从0px过渡到400px,及透明度从设定的0.5过渡到0,渐渐向外绽开,这样子就形成了涟漪效果了,当我们把span元素挂载上去我们可以看下效果,接下来我们将通过JavaScript来获取鼠标点击位置来决定绽开的位置。


4.gif


注意


div中的span标签删除或者注释掉,后面我们将使用JavaScript来添加这个span标签


div中的span标签删除或者注释掉,后面我们将使用JavaScript来添加这个span标签


div中的span标签删除或者注释掉,后面我们将使用JavaScript来添加这个span标签


码出点击效果

这里我们先引入jQuery这个库,为了方便使用,这里我就使用cdn方式来引入。



这里给大家推荐一个国内的CDN库:http://www.bootcdn.cn



<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>

创建一个addRipple方法,先创建一个绑定overlay类的span标签,获取鼠标点击页面的xy值,绑定对应的left值和top值,绑定之后把span元素添加到div中。


设定一个定时器,当动画执行完毕后把span元素移除掉,减少内存的占用。


const addRipple = function (e) {
   let overlay = $("<span></span>")
   const x = e.clientX - e.target.offsetLeft
   const y = e.clientY - e.target.offsetTop;
   overlay.css(
      {
           left: x + 'px',
           top: y + 'px'
      }
  )
   $(this).append(overlay)
   setTimeout(() => {
       overlay.remove()
  }, 500)
}

div绑定addRipple事件,按钮就实现跟开头效果图一样的页面啦!


$('#btn').click(addRipple);

1.gif


5.png


链接:https://juejin.cn/post/6991752850644664333

收起阅读 »

我给公司封装的组件帮公司提效了60%

前置内容 在公司开发中或多或少都会有几个管理系统的项目,而对于这些系统大多页面都是表单、表格组成,为了不花费太多精力在这些不那么需要定制化的页面上,一般都会选择去用组件库完成,这个时候就如果将这些简单、枯燥的事情用配置项完成,把精力放在更有挑战的事情上,那么工...
继续阅读 »

前置内容


在公司开发中或多或少都会有几个管理系统的项目,而对于这些系统大多页面都是表单、表格组成,为了不花费太多精力在这些不那么需要定制化的页面上,一般都会选择去用组件库完成,这个时候就如果将这些简单、枯燥的事情用配置项完成,把精力放在更有挑战的事情上,那么工作摸鱼的时间又多了不少。下面就分享下我花了近一个月的时间为公司封装的组件。


涉及到的技术:



  • vue

  • element-ui


就基于上面两个来实现的,使用起来也非常简单,并不需要你去记太多prop,就是element哪些。你只需要这么简单的配置,表单项就出来了







1.gif


选择之后, testFormModel对象就会是这样


{
 treeProp: '三级 1-1-1',
}

为了方便使用, 我需要下拉提供这么几个功能:



  • 根据url动态请求数据(可携带参数,且参数变动之后重新发起请求)

  • 需要获取动态请求回来的数据

  • 需要提供数据格式化的能力(数据格式化显示是为了不变动数据的情况正确显示页面)

  • 需要实现过滤功能


{
 label: '树形下拉',
 // formModel绑定的属性
 prop: 'treeProp',
 type: 'treeSelect',
 url: 'xxxx',
 params: {
   query: 'all',
},
 resolveData: (data) => {
   this.xxx = data
}
 nodeKey: 'dyId',
 props: {
   label: 'name',
   children: 'sublevel'
},
 multiple: true,
 checkStrictly: false,
 filterable: true,
}

效果图💗


2.gif



简单的配置下这树形功能就非常的强大了,同样也支持用户自己去配置懒加载数据



{
 lazy: true,
 load: this.loadNode,
}

这个时候就不需要去配置动态请求的哪些的配置,由用户自己实例接口的懒加载数据请求


我们需要完成什么样的东西?


整体效果图💗


3.gif



  • 配合侧边栏,校验失败右侧对应label标红

  • 点击右侧label视图自动滚动到对应表单项,并激活表单项

  • 侧边栏可配置(可以需要,也可以不配置)



这就是后面我们要实现的组件库,本文只是抛转引玉,对后面要做的事件大概说下,后续文章会很详细的分享整个组件封装的架构和思路,对于表单的一些「原子组件」的实现,之前也分享过一些,感兴趣的可以关注我的专栏:组件封装最佳实践



表单支持的组件



  • el-input/el-autocomplete

  • el-select

  • treeSelect 「集成」

  • el-switch

  • el-checkbox/el-radio/el-raiod-group/el-checkbox-group

  • el-date-picker/el-time-picker

  • el-cascader/el-cascader-panel

  • table 「集成」


有些功能还在完善,暂时就没有共享代码。后续会发布到npm提供下载,通过Vue.use()使用插件的方式使用


4.png


如何使用?


main.js


// 引用插件
import './plugins'

plugins.js


import './element-ui'
import './dynamic-ui'


这套组件是依赖element-ui封装的,所以前提是需要使用element



dynamic-ui.js


import Vue from 'vue'
import dynamicUI from 'dynamic-ui'
import 'dynamic-ui/lib/index.scss'
// 向表单添加组件类型
import DynamicTable from '@/components/DynamicTable/src/index.vue'

import { getToken } from '@/utils/auth'
import request from '@/utils/request'

Vue.component(DynamicTable.name, DynamicTable)
Vue.use(dynamicUI, {
 request, // 动态请求数据的方法
 baseURI: process.env.VUE_APP_BASE_API,
 parseData: () => {}, // 解析接口返回数据的方法
 requestHeaders: { // 请求头
   Authorization: getToken()
},
 // 需要动态添加到表单组件的类型
 addFormComponent: [
  {
     type: 'table',
     name: DynamicTable.name
  }
]
})

组件库提供的功能



  • 传入[全局, 局部]的request

  • 传入[全局, 局部]的parseData

  • 传入requestHeaders请求头参数

  • 动态添加组件作为表单项


支持动态请求的数据组件



  • select

  • treeSelect

  • checkbox/radio

  • table

  • cascader/cascader-panel


以上几个组件类型在element基础上进行了扩展,允许用户动态请求数据,统一prop这样



三者的使用场景


这里分别说下parseData/formatter/resoveData的使用场景


parsseData


一般我们在使用axios都会封装响应拦截器,做业务码的统一处理,但一般不会去变动data,现在有个问题是这样的,后端返回的数据是这样的


{
code: '200',
message: 'xxx',
data: {
...
pageData: [] // 这个才是我们要的数据
}
}

只有个别数据是这样的格式,这个时候全局配置就不去动,可以允许去传递单个组件的parseData来解决这个问题


formatter


用于在不影响原有数据的情况下格式化数据以正确显示页面,比如这样


formatter: (value) => {
return `dy-${value}`
},

5.png


resolveData


获取响应式的数据:直接变动数据,可直接影响页面。


resolveData: (data) => {
console.log(data)
data[0].name = '欢迎关注:前端自学驿站'
},

6.png



以上所有类型都会在请求参数变动之后重新请求数据,所以如果后端提供分页接口,前端也就能实现分页懒加载的功能



表格组件支持的类型


表格会支持



  • 展示模式

  • 编辑模式(支持所有表单组件类型,包括用户动态添加的)



本来打算将组件的功能大致的过一遍,但是写到这太晚了,有点肝不动了~,目前就是表格编辑模式这块还有些地方需要完善,后续完善测试通过之后就会立马发布,欢迎大家持续关注~



写在最后


如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下,我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。


业精于勤,荒于嬉



链接:https://juejin.cn/post/6991481121540145160

收起阅读 »

面对 this 指向丢失,尤雨溪在 Vuex 源码中是怎么处理的

1. 前言简单再说说 this 指向和尤大在 Vuex 源码中是怎么处理 this 指向丢失的。 2. 对象中的this指向 var person = { name: '若川', say: function(text){ console.log...
继续阅读 »

1. 前言简单再说说 this 指向和尤大在 Vuex 源码中是怎么处理 this 指向丢失的。


2. 对象中的this指向


var person = {
name: '若川',
say: function(text){
console.log(this.name + ', ' + text);
}
}
console.log(person.name);
console.log(person.say('在写文章')); // 若川, 在写文章
var say = person.say;
say('在写文章'); // 这里的this指向就丢失了,指向window了。(非严格模式)

3. 类中的this指向


3.1 ES5


// ES5
var Person = function(){
this.name = '若川';
}
Person.prototype.say = function(text){
console.log(this.name + ', ' + text);
}
var person = new Person();
console.log(person.name); // 若川
console.log(person.say('在写文章'));
var say = person.say;
say('在写文章'); // 这里的this指向就丢失了,指向 window 了。

3.2 ES6


// ES6
class Person{
construcor(name = '若川'){
this.name = name;
}
say(text){
console.log(`${this.name}, ${text}`);
}
}
const person = new Person();
person.say('在写文章')
// 解构
const { say } = person;
say('在写文章'); // 报错 this ,因为ES6 默认启用严格模式,严格模式下指向 undefined

4. 尤大在Vuex源码中是怎么处理的


先看代码


class Store{
constructor(options = {}){
this._actions = Object.create(null);
// bind commit and dispatch to self
// 给自己 绑定 commit 和 dispatch
const store = this
const { dispatch, commit } = this
// 为何要这样绑定 ?
// 说明调用commit和dispach 的 this 不一定是 store 实例
// 这是确保这两个函数里的this是store实例
this.dispatch = function boundDispatch (type, payload) {
return dispatch.call(store, type, payload)
}
this.commit = function boundCommit (type, payload, options) {
return commit.call(store, type, payload, options)
}
}
dispatch(){
console.log('dispatch', this);
}
commit(){
console.log('commit', this);
}
}
const store = new Store();
store.dispatch(); // 输出结果 this 是什么呢?

const { dispatch, commit } = store;
dispatch(); // 输出结果 this 是什么呢?
commit(); // 输出结果 this 是什么呢?

输出结果截图


结论:非常巧妙的用了calldispatchcommit函数的this指向强制绑定到store实例对象上。如果不这么绑定就报错了。


4.1 actions 解构 store


其实Vuex源码里就有上面解构const { dispatch, commit } = store;的写法。想想我们平时是如何写actions的。actions中自定义函数的第一个参数其实就是 store 实例。


这时我们翻看下actions文档https://vuex.vuejs.org/zh/guide/actions.html


const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment (state) {
state.count++
}
},
actions: {
increment (context) {
context.commit('increment')
}
}
})

也可以用解构赋值的写法。


actions: {
increment ({ commit }) {
commit('increment')
}
}

有了Vuex源码构造函数里的call绑定,这样this指向就被修正啦~不得不说祖师爷就是厉害。这一招,大家可以免费学走~


接着我们带着问题,为啥上文中的context就是store实例,有dispatchcommit这些方法呢。继续往下看。


4.2 为什么 actions 对象里的自定义函数 第一个参数就是 store 实例。


以下是简单源码,有缩减,感兴趣的可以看我的文章 Vuex 源码文章


class Store{
construcor(){
// 初始化 根模块
// 并且也递归的注册所有子模块
// 并且收集所有模块的 getters 放在 this._wrappedGetters 里面
installModule(this, state, [], this._modules.root)
}
}

接着我们看installModule函数中的遍历注册 actions 实现


function installModule (store, rootState, path, module, hot) {
// 省略若干代码
// 循环遍历注册 action
module.forEachAction((action, key) => {
const type = action.root ? key : namespace + key
const handler = action.handler || action
registerAction(store, type, handler, local)
})
}

接着看注册 actions 函数实现 registerAction


/**
* 注册 mutation
* @param {Object} store 对象
* @param {String} type 类型
* @param {Function} handler 用户自定义的函数
* @param {Object} local local 对象
*/
function registerAction (store, type, handler, local) {
const entry = store._actions[type] || (store._actions[type] = [])
// payload 是actions函数的第二个参数
entry.push(function wrappedActionHandler (payload) {
/**
* 也就是为什么用户定义的actions中的函数第一个参数有
* { dispatch, commit, getters, state, rootGetters, rootState } 的原因
* actions: {
* checkout ({ commit, state }, products) {
* console.log(commit, state);
* }
* }
*/
let res = handler.call(store, {
dispatch: local.dispatch,
commit: local.commit,
getters: local.getters,
state: local.state,
rootGetters: store.getters,
rootState: store.state
}, payload)
// 源码有删减
}

比较容易发现调用顺序是 new Store() => installModule(this) => registerAction(store) => let res = handler.call(store)


其中handler 就是 用户自定义的函数,也就是对应上文的例子increment函数。store实例对象一路往下传递,到handler执行时,也是用了call函数,强制绑定了第一个参数是store实例对象。


actions: {
increment ({ commit }) {
commit('increment')
}
}

这也就是为什么 actions 对象中的自定义函数的第一个参数是 store 对象实例了。


好啦,文章到这里就基本写完啦~相对简短一些。应该也比较好理解。


5. 最后再总结下 this 指向


摘抄下面试官问:this 指向文章结尾。


如果要判断一个运行中函数的 this 绑定, 就需要找到这个函数的直接调用位置。 找到之后
就可以顺序应用下面这四条规则来判断 this 的绑定对象。



  1. new 调用:绑定到新创建的对象,注意:显示return函数或对象,返回值不是新创建的对象,而是显式返回的函数或对象。

  2. call 或者 apply( 或者 bind) 调用:严格模式下,绑定到指定的第一个参数。非严格模式下,nullundefined,指向全局对象(浏览器中是window),其余值指向被new Object()包装的对象。

  3. 对象上的函数调用:绑定到那个对象。

  4. 普通函数调用: 在严格模式下绑定到 undefined,否则绑定到全局对象。


ES6 中的箭头函数:不会使用上文的四条标准的绑定规则, 而是根据当前的词法作用域来决定this, 具体来说, 箭头函数会继承外层函数,调用的 this 绑定( 无论 this 绑定到什么),没有外层函数,则是绑定到全局对象(浏览器中是window)。 这其实和 ES6 之前代码中的 self = this 机制一样。




链接:https://juejin.cn/post/6991701614612447239


收起阅读 »

我在几期薅羊毛活动中学到了什么~

前言 为什么突然想写一篇总结了呢,其实也是被虐的。今年 3 月份初期,我们商城接了一个 XX 银行的一分购活动(说白点就是薅羊毛),那时候是活动第一期,未曾想到活动入口开放时,流量能直接将 cpu 冲至 100%,导致服务短暂的 502 了。。期间采取了紧急方...
继续阅读 »

前言


为什么突然想写一篇总结了呢,其实也是被虐的。今年 3 月份初期,我们商城接了一个 XX 银行的一分购活动(说白点就是薅羊毛),那时候是活动第一期,未曾想到活动入口开放时,流量能直接将 cpu 冲至 100%,导致服务短暂的 502 了。。期间采取了紧急方案到活动结束,但未曾想到还有活动二期,以及上周刚上线的活动三期。想着最近这段时间也做了一些事情,还有遇到的一些坑点,趁此机会,就不偷懒记录一下吧。


活动一期到三期具体做了些什么


技术背景&瓶颈


项目是基于 Vue+SSR 架构的,且没有做缓存处理,没做缓存的主要原因第一个是原本应用 tps 比较低,改造动力不强,并且页面渲染结果中包含了用户数据以及服务端时间,没法在不经过改造的情况下直接上缓存。所以当一期活动大流量冲击时,高并发情况下很容易将 cpu 打至 100%。


一期在未知情况下,服务直接扛不住了,当时为了活动能正常进行,首要方案就是先加机器扛住部分压力,紧接着就是加缓存,目前有两种缓存方案,缓存页面或缓存组件,但由于我们的需要缓存的商品详情页组件涉及到动态信息,可维护性太差,心智成本高,最终选择了前者。我们整理了一下商详页有关动态变化的信息数据(与时间/用户相关等类型的数据),在活动期间,紧急屏蔽了部分不影响功能的动态内容,然后页面上 CDN。


活动结束后,我们做了下复盘,要像应用要能保障大流量情况下稳定运行,性能优化处理是避免不了的了。为此我们做了以下四大方案:




  1. 对数据做动静分离: 我们可以将数据分类成动静两类,静态数据即是一段时间内,不随时间/用户改变,动态数据则是相反的,经常变动,与时间有关,有用户相关等类型的数据都可以归类为动态数据。原页面无法上缓存的最大阻碍就是,就是在 node 渲染模板时,会默认获取用户数据,或是在 asyncData 中调用用户相关的接口;此外,还会设置服务端时间等动态数据。所以思路就是将静态数据放在 node 获取,将动态数据放到客户端(浏览器读取 asyncData、mounted 等浏览器生命周期里)获取保证服务端的清洁。




  2. 页面接入 CDN: 经过动静态分离的改造后,已经可以将其路径加入 cdn。但需要注意路径上 query 等参数是否会影响渲染,如果影响,需要把逻辑搬到客户端,同时需要注意一下过期时间(如 10 分钟)是否会对业务产生影响




  3. 应用缓存: 如果在比较糟糕的情况下,cdn 失效了导致回源率上升,应用本身还是需要做好准备。这就要根据项目需要去选择内存缓存/redis 缓存。




  4. 自动降级: 在极端的情况下,前面的缓存都没挡住流量,就需要终极方案:降级渲染。所谓降级渲染,就是不进入路由,直接将空模板返回,完全交给浏览器去做渲染。这样做的最大好处就是完全避免了 node 压力,将一个 ssr 应用变成了静态应用。缺点也是显而易见的,用户看到的是空模板,需要等待初始化。那如何自动降级呢,可以通过定时器时时检测 cpu、负载等压力,来确定当前机器的负载,从而决定是否降级;也可以通过 url 的 query 上是否携带特定标识,显式地决定是否降级。




对项目方案做了以上性能优化接下来就是压测,也算是顺利上线了。🤪


二期活动没过多久又来了,不过如我们预期,项目很稳定地扛住了压力,期间也增加了流量接口,并加友好提示等优化。但其中一个痛点时需要针对几个特殊商品去做个文案处理,这几个文案非接口返回,也是临时性的一些醒目提示,没必要放在详情页接口中返回。由于时间也很紧急,我们不确定后面还有没有这种特定的文案需求(和具体的页面以及特定的区域关联),决定还是暂时写一些 low code:针对特定的活动商品 id,临时添加文案,活动下线之后,把文案去除。这样做的风险性是有的,毕竟代码是临时性的,需要上下线,并且有时间延迟。但好在活动结束时是周末,最后一天流量访问并不大,给了相对应的引导文案以及售后处理,评估下来影响不大,尚可接受。


以下图片商品详情页和商品购买页需要加的特定文案:


WechatIMG61.png


WechatIMG62.png


薅羊毛活动是真香现场吗~~6 月底产品就和我打了个招呼,说 XX 活动又要有三期了,但整体方案依旧和二期一样不变。我内心:还来???(小声说句打工人太苦了),由于最终时间没定下来,也有了二期的教训之后,和后端同学也一起商量了一下,把活动商品往配置化方向考虑,放在我们配置后台中文案模块且是可行的。针对商品详情页,考虑到不破坏动静分离,先确定下配置化接口返回的数据是静态的,可以放在服务端获取。以下具体三期做的事情:



  1. 将参与活动商品的文案做成配置化,从配置接口获取,去除 low code

  2. 整理大流量活动页(例如商详页)的接口,放在客户端的接口需要做限流,接口达到一定的 tps 后,返回 429 状态,前端要做容错处理,页面功能正常访问,屏蔽限流接口错误。

  3. 针对购买限流接口,需要给 busy 提示(活动太火爆了,请稍后再试)


// 统一在 getResponseErrorInterceptor 处针对 429 状态做处理
export const getResponseErrorInterceptor = ({ errorCallback }) => (error) => {
if (!isClient) {
...
} else {
// 429 Code 服务报错需要支持不弹出错误提示
if (+error.response.status === 429) {
// 针对限流接口,且需要 busy 提示时增加 needBusyMsg 属性
errorCallback(error.config.needBusyMsg ? '活动太火爆了,请稍后再试' : null);
} else {
...
);
}
}

return throwError(error);
};

结束了上周一周忙碌的压测和测试,三期终于上线了。👏👏👏👏


想了解更多 Vue SSR 性能优化方案可以移步到这里: Vue SSR 性能优化实践


实际过程中遇到的一些 Coding Question



  1. 本地项目(vue-ssr 架构)里,一个动态接口放在服务端获取时,有一段代码很 easy,一个是否是会员的标识去开通会员按钮的显隐,代码如下(代码有简化):


<redirect
v-if="!isVip"
:link="link"
type="h5"
>
开通会员<ui-icon name="arrow-right" />
</redirect>

本地中虽然运行正常,但是会有如下警告:
vue.esm.js:6428 Mismatching childNodes vs. VNodes: NodeList(2) [div, a.DetailVip-right.Redirect] (2) [VNode, VNode]


[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render.


但更新到测试环境中,页面会失效,点击失效等。会报有如下错误:
Failed to execute 'appendChild' on 'Node': This node type does not support this method


分析


Vue SSR 指南在客户端激活里刚好说到了一些需要注意的坑,使用「SSR + 客户端混合」时,需要了解的一件事是,浏览器可能会更改的一些特殊的 HTML 结构。例如 table 中漏写<tbody>,像以下几种情况也会导致导致服务端返回的静态 HTML 和浏览器端渲染的内容不一致:



  1. 无效的HTML(例如:<p><p>Text</p></p>

  2. 服务器与客户端的不同状态

  3. 例如日期、时间戳和随机化等不确定变量的影响

  4. 第三方脚本影响到了组件的渲染

  5. 需要身份验证相关时


当然确定原因之后对症下药,总结有几种办法可以解决此问题:



  1. 检查相关代码,确保 HTML 有效

  2. 最简单粗暴的一个方法就是:用v-show去代替v-if,要知道构建时生成的HTML是无状态的,应用程序中与身份验证相关的所有部分应该只在客户端呈现,具体可以 diff 下获取数据以及在服务器/客户端呈现的内容,解决服务器和客户端之间的状态不一致

  3. 面对第三方脚本这类的,可以通过将组件包装在标签中来避免在服务器端渲染组件

  4. .....(欢迎补充)


针对此类问题,还可以看看这篇文章:blog.lichter.io/posts/vue-h…


2: 同样的 h5 页面,在浏览器中打开配置生效,而在公众号&小程序中打开却失效了?


三期的时候,我们把活动商品 id 和对应文案做成了配置化处理。
配置方式如下:


WechatIMG64.png
获取商品配置内容经过 JSON.stringify()之后,毋庸置疑会得到如下字符串:


455164527672033280|龙支付 立减 10 元|满 40 立减 10(仅限 XX 卡)#\\n623841656577658880|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)#\\n350947143063699456|龙支付测试 立减 10 元(仅限 XX 卡)|满 40 立减 10 测试(仅限 XX 卡)#

在详情页获取所有的商品 id 列表信息,我们用的#做区分,写了一个简单的正则如下:


activityItems() {
return this.getFieldValue('activity_item')?.split('#\\n');
},

但在公众号里面打开我们的 h5 链接,会将#自动转义成\,内容会变成:


455164527672033280|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)\\\n623841656577658880|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)\\\n350947143063699456|龙支付测试 立减 10 元(仅限 XX 卡)|满 40 立减 10 测试(仅限 XX 卡)\

啊,这,,,不是吧??(发现时内心几乎是崩溃的)😩 解决方式立马把#字符换成不被转移的字符;


另外在小程序中打开失效是因为延用二期的方案,当时做了限制判断,只需要在主站和主 app 中打开有效,小程序设有自己单独的 appid,三期活动有多方入口,把该限制放开即可。


总结


薅羊毛参与了三期,也是积累了一些经验,踩了一些坑吧,想着太久没写了该记录一下了,先总结到这里,还有忘记的再补充~



链接:https://juejin.cn/post/6989618653998088228

收起阅读 »

webpack5 和 webpack4 的区别有哪些 ?

1、Tree Shaking 作用: 如果我们的项目中引入了 lodash 包,但是我只有了其中的一个方法。其他没有用到的方法是不是冗余的?此时 tree-shaking 就可以把没有用的那些东西剔除掉,来减少最终的bundle体积。 usedExports...
继续阅读 »

1、Tree Shaking


作用: 如果我们的项目中引入了 lodash 包,但是我只有了其中的一个方法。其他没有用到的方法是不是冗余的?此时 tree-shaking 就可以把没有用的那些东西剔除掉,来减少最终的bundle体积。



usedExports : true, 标记没有用的叶子




minimize: true, 摇掉那些没有用的叶子



  // webpack.config.js中
module.exports = {
optimization: {
usedExports: true, //只导出被使用的模块
minimize : true // 启动压缩
}
}

由于 tree shaking 只支持 esmodule ,如果你打包出来的是 commonjs,此时 tree-shaking 就失效了。不过当前大家都用的是 vue,react 等框架,他们都是用 babel-loader 编译,以下配置就能够保证他一定是 esmodule


image.png


webpack5的 mode=“production” 自动开启 tree-shaking。


2、压缩代码


1.webpack4


webpack4 上需要下载安装 terser-webpack-plugin 插件,并且需要以下配置:



const TerserPlugin = require('terser-webpack-plugin')

module.exports = {
// ...other config
optimization: {
minimize: !isDev,
minimizer: [
new TerserPlugin({
extractComments: false,
terserOptions: {
compress: {
pure_funcs: ['console.log']
}
}
}) ]
}

2.webpack5

内部本身就自带 js 压缩功能,他内置了 terser-webpack-plugin 插件,我们不用再下载安装。而且在 mode=“production” 的时候会自动开启 js 压缩功能。



如果你要在开发环境使用,就用下面:



  // webpack.config.js中
module.exports = {
optimization: {
usedExports: true, //只导出被使用的模块
minimize : true // 启动压缩
}
}

3.js 压缩失效问题

当你下载 optimize-css-assets-webpack-plugin ,执行 css 压缩以后,你会发现 webpack5 默认的 js 压缩功能失效了。先说 optimize-css-assets-webpack-plugin 的配置:



npm install optimize-css-assets-webpack-plugin -D



module.exports = { 
optimization: {
minimizer: [
new OptimizeCssAssetsPlugin()
]
},
}


此时的压缩插件 optimize-css-assets-webpack-plugin 可以配置到 plugins 里面去,也可以如图配置到到 optimization 里面。区别如下:



配置到 plugins 中,那么这个插件在任何情况下都会工作。 而配置在 optimization 表示只有 minimize 为 true 的时候才能工作。



当安装 optimize-css-assets-webpack-plugin 以后你去打包会发现原来可以压缩的 js 文件,现在不能压缩了。原因是你指定的压缩器是



optimize-css-assets-webpack-plugin 导致默认的 terser-webpack-plugin 就会失效。解决办法如下:



npm install terser-webpack-plugin -D



 optimization: {
minimizer: [
new TerserPlugin({
extractComments: false,
terserOptions: {
compress: { pure_funcs: ['console.log'] },
},
}),
new OptimiazeCssAssetPlugin(),
]
}

即便在 webpack5 中,你也要像 webpack4 中一样使用 js 压缩。


4.注意事项

在webpack5里面使用 optimize-css-assets-webpack-plugin 又是会报错,因为官方已经打算要废除了,请使用替换方案:



npm i css-assets-webpack-plugin -D



3、合并模块



普通打包只是将一个模块最终放到一个单独的立即执行函数中,如果你有很多模块,那么就有很多立即执行函数。concatenateModules 可以要所有的模块都合并到一个函数里面去。



optimization.concatenateModules = true


配置如下:


module.exports = {
optimization: {
usedExports: true,
concatenateModules: true,
minimize: true
}
}

此时配合 tree-shaking 你会发现打包的体积会减小很多。


4、副作用 sideEffects



webpack4 新增了一个 sideEffects 的功能,容许我们通过配置来标识我们的代码是否有副作用。




这个特性只有在开发 npm 包的时候用到



副作用的解释: 在utils文件夹下面有index.js文件,用于系统导出utils里面其他文件,作用就是写的少, 不管 utils 里面有多少方法,我都只需要引入 utils 即可。


// utils/index.js
export * from './getXXX.js';
export * from './getAAA.js';
export * from './getBBB.js';
export * from './getCCC.js';

 // 在其他文件使用 getXXX 引入
import {getXX} from '../utils'

此时,如果文件 getBBB 在外界没有用到,而 tree-shaking 又不能把它摇掉咋办?这个 getBBB 就是副作用。你或许要问 tree-shaking 为什么不能奈何他?原因就是:他在 utils/index.js 里面使用了。只能开启副作用特性。如下:


// package.json中
{
name:“项目名称”,
....
sideEffects: false
}

// webpack.config.js

module.exports = {
mode: 'none',
....
optimization: {
sideEffects: true
}
}

副作用开启:



(1)optimization.sideEffects = true 开启副作用功能




(2)package.json 中设置 sideEffects : false 标记所有模块无副作用



说明: webpack 打包前都会检查项目所属的 package.json 文件中的 sideEffects 标识,如果没有副作用,那些没有用到的模块就不需要打包,反之亦然。此时,在webpack.config.js 里面开启 sideEffects。


5、webpack 缓存


1.webpack4 缓存配置

支持缓存在内存中



npm install hard-source-webpack-plugin -D



const HardSourceWebpackPlugin = require('hard-source-webpack-plugin') 

module.exports = {
plugins: [
// 其它 plugin...
new HardSourceWebpackPlugin(),
] }

2. webpack5 缓存配置

webpack5 内部内置了 cache 缓存机制。直接配置即可。



cache 会在开发模式下被设置成 type: memory 而且会在生产模式把cache 给禁用掉。



// webpack.config.js
module.exports= {
// 使用持久化缓存
cache: {
type: 'filesystem',
cacheDirectory: path.join(__dirname, 'node_modules/.cac/webpack')
}
}


type 的可选值为: memory 使用内容缓存,filesystem 使用文件缓存。




当 type=filesystem的时候设置cacheDirectory才生效。用于设置你需要的东西缓存放在哪里?



6、对loader的优化



webpack 4 加载资源需要用不同的 loader




  • raw-loader 将文件导入为字符串

  • url-loader 将文件作为 data url 内联到 bundle文件中

  • file-loader 将文件发送到输出目录中


image.png



webpack5 的资源模块类型替换 loader




  • asset/resource 替换 file-loader(发送单独文件)

  • asset/inline 替换 url-loader (导出 url)

  • asset/source 替换 raw-loader(导出源代码)

  • asset


image.png


webpack5


7、启动服务的差别


1.webpack4 启动服务

通过 webpack-dev-server 启动服务


2.webpack5 启动服务

内置使用 webpack serve 启动,但是他的日志不是很好,所以一般都加都喜欢用 webpack-dev-server 优化。


8. 模块联邦(微前端)



webpack 可以实现 应用程序和应用程序之间的引用。



9.devtool的差别


sourceMap需要在 webpack.config.js里面直接配置 devtool 就可以实现了。而 devtool有很多个选项值,不同的选项值,不同的选项产生的 .map 文件不同,打包速度不同。


一般情况下,我们一般在开发环境配置用“cheap-eval-module-source-map”,在生产环境用‘none’。


devtool在webpack4和webpack5上也是有区别的



v4: devtool: 'cheap-eval-module-source-map'




v5: devtool: 'eval-cheap-module-source-map'



10.热更新差别



webpack4设置



image.png



webpack5 设置



如果你使用的是bable6,按照上述设置,你会发现热更新无效,需要添加配置:


  module.hot.accept('需要热启动的文件',(source)=>{
//自定义热启动
})

当前最新版的babel里面的 babel-loader已经帮我们处理的热更新失效的问题。所以不必担心,直接使用即可。


如果你引入 mini-css-extract-plugin 以后你会发现 样式的热更新也会失效。


只能在开发环境使用style-loader,而在生产环境用MinicssExtractPlugin.loader。 如下:


image.png


11、使用 webpack-merge 的差别



webpack4 导入



const merge = require('webpack-merge);



webpack 5 导入



const {merge} = require('webpack-merge');


12、 使用 copy-webpack-plugin 的差别


//webpack.config.js
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
plugins: [
// webpack 4
new CopyWebpackPlugin(['public']),

// webpack 5
new CopyWebpackPlugin({
patterns: [{
from: './public',
to: './dist/public'
}]
})
]
}

webpack5 支持的新版本里面需要配置的更加清楚。


链接:https://juejin.cn/post/6990869970385109005

收起阅读 »

与大厂面试官的高端博弈、顶级拉扯

前言 最近是跳槽季,发现有小伙伴在一些非技术的软性问题上答的不是很好。 众所周知,程序员情商偏低,而这些软性问题,恰恰都具有一定欺骗性和吹牛皮成分在里边,对于演技不好的直男癌,简直就是天生克星。 其实不用太担心,软性问题往往就那几个,稍加训练和准备,你就可以成...
继续阅读 »

前言


最近是跳槽季,发现有小伙伴在一些非技术的软性问题上答的不是很好。


众所周知,程序员情商偏低,而这些软性问题,恰恰都具有一定欺骗性和吹牛皮成分在里边,对于演技不好的直男癌,简直就是天生克星。


其实不用太担心,软性问题往往就那几个,稍加训练和准备,你就可以成为一位高端名猿。


题目


第 1 题:说一下你自己的缺点


演技考验:4星


这题处处暗藏杀鸡,很多小伙伴会推心置腹,诚实的将自己所有缺点说出来,比如:“我脾气不好”、“我学习东西慢”、“我贪财好.色”。


这种缺点,人人都有,但大可不必说出来。


很多小伙伴说,我就要做real man,你爱喜欢不喜欢。外面的那些人都是虚伪装哔的妖艳剑货,我就要做真实的自己。


其实,这根本不是真实的自己,这是情商低。不懂得逢场作戏,不懂得在特定的场合说正确的话。


所以,如何说一个缺点,但又不完全是缺点的缺点,还能让人觉得这是优点的缺点


你应该这么演:


唉声叹气,微笑、头部倾斜45度看向地面,说:我偶尔会因为专研技术问题,而搞到深夜,把自己弄得很累。今后我会多注意,把控好技术学习和工作状态的平衡。


这里几个细节注意以下



  • “头部倾斜45度看向地面”从行为学上让人感觉你没有架子,谦虚好相处。

  • 回答中多次用了“搞”、“弄”、“累”等动词,让人觉得你不做作,接地气。

  • 我说我的缺点是因为太爱学习,就好像是说,我的缺点是因为我太有钱,这根本不是缺点,而是优点,只是换了一种说法,直接上天日龙


这就是传说中的反套路学,程序猿和面试官之间的高端博弈,顶级拉扯


如果你有更好的答案或想法,欢迎在题目对应的github下留言:第一名的小蝌蚪


第 2 题:你对加班什么看法?


演技考验:3星


这题真的是炒鸡容易被问到。首先,没人有会喜欢加班,但是,但凡出现“敏捷开发”、“谈加班看法”的,大概率都是经常加班的公司。


我觉得只要不是业界那几个著名黑工厂,正常的加班,都是可以接受的


在大厂工作了8年,总结了一下导致我加班的几个原因:



  • 不爱思考

  • 能力不行

  • 贪玩好.色


  • 第五点不能说,懂的都懂


这8年里,我对加班的心态,从早期的抵触,到中期的忍耐,到现在的主动,我发现,你越不想加班,加班就越来找你,还不如主动进攻,主动学习技术,主动思考工作中优化点。


将重复性工作,通过开发一些工程化、自动化工具去代替,慢慢的你会发现,工作会事半功倍,不再那么被动,真的比被按住头逼你加班要好受很多。


撸迅曾说过:“工作就像强J,与其反抗,不如闭上眼睛好好享受”,很悲哀,但也是中年屌丝生存之道


所以,这道题有一个很不错的答案:
如果是工作需要我会义不容辞加班。但同时,我也会思考工作中的优化点,将重复性工作,通过开发一些工程化、自动化工具去代替,提高工作效率,减少不必要的加班


如果你有更好的答案或想法,欢迎在这题目对应的github下留言:第一名的小蝌蚪


第 3 题:你为什么从上家离职


演技考验:2星


有几个注意点,在说离职原因的时候,有几个大忌:



  • 不能说你跟上家leader闹掰

  • 不能说自己是被开除的

  • 不能说上家公司黄了,所以我跳槽了


反正千万记住,不要说任何上家公司和leader的坏话。


正确的表演应该是,不管上家对你怎么样,都要一副感恩戴德的态度去展示给面试官。是因为某种不可抗拒因素所以才导致你离职


有一个常规回答:上家公司平台趋于稳定,想到一家更大的平台去开阔视野,更好的展现自己的实力,让自己创造更大的价值


见过几个比较搞笑的回答:“我老婆要生了,想去一家能准点下班的公司”、“我失恋了,想去一个地方重新开始”,嘻嘻,但老铁还是别这么回答就好


如果你有更好的答案或想法,欢迎在这题目对应的github下留言:第一名的小蝌蚪


第 4 题:面对hr和面试官刁难,如何应对


演技考验:5星


这个情况是我真实遇到的,我职业生涯第二家公司(某知名大厂),就被当时的hr刁难了


她当时很高傲,还很藐视我,说过几句话让我印象深刻



  • 你背景很一般啊,上家公司是个小公司,我们一般只要有大厂经历的候选人

  • 我们都招985的研究生,你的综合素质没有我想象的好

  • 你简历里面写的那几个项目经历,都太一般了,没有任何知名度,你凭什么觉得我们会要你呢?


当时面完以后,我其实很生气,觉得那个hr人品有问题,但从头到尾都忍住了。


最后诡异的是。。。我还拿到了offer,拿到了期望的薪资和职级。。。。。


很久以后问过她为什么要这么面试候选人


她的回答,大意是:“hr一般这么刁难你,都是想压你的气势,让你受挫,让你觉得配不上我们,增加你接收offer的成功率,还能增加将来谈判薪资的主动权。”


这绝对是一种对人性心理的操控了。。。。


第二点,尤为重要“测试你的情商,那些随便刺激一下,就暴怒、就疯狂反驳、情绪失控的候选人,是绝对不能要的。能全程忍下来,且不卑不亢,保持自信的人,证明了你的抗压能力,确保你今后在工作中能处理好各种工作关系和极端情况”


她的回答令我醍醐灌顶,可能是代码敲多了,从来没有想过,原来人性也可以和代码一样,被度量,被证明


从那以后,对大厂的hr专业度,还是挺认可的


当然,这种刁难也可能是pua的早期萌芽,细思极恐。。。


所以针对候选人在面试过程中被刁难情况,应该如何表现和回答呢?


大家可以先自行思考一下


我把答案放在了github中,稍后公布,如果你有更好的想法,也可以给我留言:第一名的小蝌蚪


总结


以上4题,揭露了些面试中的套路和反套路,展示了逢场作戏和演技的技巧,很多人可能会觉得这样很虚伪


这里又要引用撸迅的一句名言:“当混浊变成一种常态,清白也会是一种罪行”


虚伪的人,有时候也是一种自我保护。


也许这并不虚伪,而是一种生存法则,因为不这么做,就会被这么做的人弄si。


希望大家针对上面提出的问题,和对应的答案,触发一些思考,总结自身,完善自己。不仅仅是面试,在工作也是要这样。


由于篇幅限制,下期会公布另外几道软性问题的答案:



  • 1.职场上,你的技术方案和同事不合,如何处理?

  • 2.如果你的方案和领导不合,如何处理?

  • 3.你未来五年的规划是什么

  • 4.你如何看待ppt文化

  • 5.你的入职,能给我们带来什么价值



链接:https://juejin.cn/post/6990533805786431524

收起阅读 »

vuepress的使用

快速上手 前提条件 VuePress 需要 Node.js (opens new window)>= 8.6 1.安装vuepress yarn add -D vuepress # npm install -D vuepress 2.创建你的第一篇文...
继续阅读 »

快速上手



前提条件


VuePress 需要 Node.js (opens new window)>= 8.6



1.安装vuepress


yarn add -D vuepress # npm install -D vuepress

2.创建你的第一篇文档


mkdir docs && echo '# Hello VuePress' > docs/README.md

3.在 package.json 中添加 scripts


{
"scripts": {
  "docs:dev": "vuepress dev docs",
  "docs:build": "vuepress build docs"
}
}

4.在本地启动服务器


yarn docs:dev # npm run docs:dev

VuePress 会在 http://localhost:8080启动一个热重载的开发服务器。


目录结构



  • docs/.vuepress: 用于存放全局的配置、组件、静态资源等。

  • docs/.vuepress/components: 该目录中的 Vue 组件将会被自动注册为全局组件。

  • docs/.vuepress/theme: 用于存放本地主题。

  • docs/.vuepress/styles: 用于存放样式相关的文件。

  • docs/.vuepress/styles/index.styl: 将会被自动应用的全局样式文件,会生成在最终的 CSS 文件结尾,具有比默认样式更高的优先级。

  • docs/.vuepress/styles/palette.styl: 用于重写默认颜色常量,或者设置新的 stylus 颜色常量。

  • docs/.vuepress/public: 静态资源目录。

  • docs/.vuepress/templates: 存储 HTML 模板文件。

  • docs/.vuepress/templates/dev.html: 用于开发环境的 HTML 模板文件。

  • docs/.vuepress/templates/ssr.html: 构建时基于 Vue SSR 的 HTML 模板文件。

  • docs/.vuepress/config.js: 配置文件的入口文件,也可以是 YMLtoml

  • docs/.vuepress/enhanceApp.js: 客户端应用的增强。


默认的页面路由























文件的相对路径页面路由地址
/README.md/
/guide/README.md/guide/
/guide/config.md/guide/config.html

主题配置


首页


默认的主题提供了一个首页(Homepage)的布局 (用于 这个网站的主页)。想要使用它,需要在你的根级 README.md 中指定 home: true。以下是一个如何使用的例子:


---
home: true
heroImage: /hero.png
heroText: Hero 标题
tagline: Hero 副标题
actionText: 快速上手 →
actionLink: /zh/guide/
features:
- title: 简洁至上
details: 以 Markdown 为中心的项目结构,以最少的配置帮助你专注于写作。
- title: Vue驱动
details: 享受 Vue + webpack 的开发体验,在 Markdown 中使用 Vue 组件,同时可以使用 Vue 来开发自定义主题。
- title: 高性能
details: VuePress 为每个页面预渲染生成静态的 HTML,同时在页面被加载的时候,将作为 SPA 运行。
footer: MIT Licensed | Copyright © 2018-present Evan You
---

更多配置项


导航栏


导航栏可能包含你的页面标题、搜索框导航栏链接多语言切换仓库链接,它们均取决于你的配置。


导航栏 Logo


// .vuepress/config.js
module.exports = {
themeConfig: {
logo: '/assets/img/logo.png',
}
}

导航栏链接


// .vuepress/config.js
module.exports = {
themeConfig: {
nav: [
{ text: 'Home', link: '/' },
{ text: 'Guide', link: '/guide/' },
{ text: 'External', link: 'https://google.com' },
{ text: 'test', link: 'test', target:'_self', rel:'' }
]
}
}

设置分组


// .vuepress/config.js
module.exports = {
themeConfig: {
nav: [
{
text: 'Languages',
items: [
{ text: 'Chinese', link: '/language/chinese/' },
{ text: 'Japanese', link: '/language/japanese/' }
]
}
]
}
}

侧边栏


想要使 侧边栏(Sidebar)生效,需要配置 themeConfig.sidebar,基本的配置,需要一个包含了多个链接的数组:


// .vuepress/config.js
module.exports = {
themeConfig: {
sidebar: [
'/',
'/page-a',
['/page-b', 'Explicit link text']
]
}
}

省略 .md 拓展名,同时以 / 结尾的路径将会被视为 */README.md,这个链接的文字将会被自动获取到


嵌套的标题链接


默认情况下,侧边栏会自动地显示由当前页面的标题(headers)组成的链接,并按照页面本身的结构进行嵌套,你可以通过 themeConfig.sidebarDepth 来修改它的行为。默认的深度是 1,它将提取到 h2 的标题,设置成 0 将会禁用标题(headers)链接,同时,最大的深度为 2,它将同时提取 h2h3 标题。


显示所有页面的标题链接


// .vuepress/config.js
module.exports = {
themeConfig: {
displayAllHeaders: true // 默认值:false
}
}

活动的标题链接


默认情况下,当用户通过滚动查看页面的不同部分时,嵌套的标题链接和 URL 中的 Hash 值会实时更新,这个行为可以通过以下的配置来禁用:


// .vuepress/config.js
module.exports = {
themeConfig: {
activeHeaderLinks: false, // 默认值:true
}
}

侧边栏分组


// .vuepress/config.js
module.exports = {
themeConfig: {
sidebar: [
{
title: 'Group 1', // 必要的
path: '/foo/', // 可选的, 标题的跳转链接,应为绝对路径且必须存在
collapsable: false, // 可选的, 默认值是 true,
sidebarDepth: 1, // 可选的, 默认值是 1
children: [
'/'
]
},
{
title: 'Group 2',
children: [ /* ... */ ],
initialOpenGroupIndex: -1 // 可选的, 默认值是 0
}
]
}
}

侧边栏的每个子组默认是可折叠的,你可以设置 collapsable: false 来让一个组永远都是展开状态。


多个侧边栏


.
├─ README.md
├─ contact.md
├─ about.md
├─ foo/
│ ├─ README.md
│ ├─ one.md
│ └─ two.md
└─ bar/
├─ README.md
├─ three.md
└─ four.md

注意


确保 fallback 侧边栏被最后定义。VuePress 会按顺序遍历侧边栏配置来寻找匹配的配置


搜索框


内置搜索


你可以通过设置 themeConfig.search: false 来禁用默认的搜索框,或是通过 themeConfig.searchMaxSuggestions 来调整默认搜索框显示的搜索结果数量


// .vuepress/config.js
module.exports = {
themeConfig: {
search: false,
searchMaxSuggestions: 10
}
}

最后更新时间


你可以通过 themeConfig.lastUpdated 选项来获取每个文件最后一次 git 提交的 UNIX 时间戳(ms),同时它将以合适的日期格式显示在每一页的底部:


// .vuepress/config.js
module.exports = {
themeConfig: {
lastUpdated: 'Last Updated', // string | boolean
}
}

上 / 下一篇链接


上一篇和下一篇文章的链接将会自动地根据当前页面的侧边栏的顺序来获取。


你可以通过 themeConfig.nextLinksthemeConfig.prevLinks 来全局禁用它们:


// .vuepress/config.js
module.exports = {
themeConfig: {
// 默认值是 true 。设置为 false 来禁用所有页面的 下一篇 链接
nextLinks: false,
// 默认值是 true 。设置为 false 来禁用所有页面的 上一篇 链接
prevLinks: false
}
}

Git 仓库和编辑链接


当你提供了 themeConfig.repo 选项,将会自动在每个页面的导航栏生成生成一个 GitHub 链接,以及在页面的底部生成一个 "Edit this page" 链接。


// .vuepress/config.js
module.exports = {
themeConfig: {
// 假定是 GitHub. 同时也可以是一个完整的 GitLab URL
repo: 'vuejs/vuepress',
// 自定义仓库链接文字。默认从 `themeConfig.repo` 中自动推断为
// "GitHub"/"GitLab"/"Bitbucket" 其中之一,或是 "Source"。
repoLabel: '查看源码',
// 以下为可选的编辑链接选项
// 假如你的文档仓库和项目本身不在一个仓库:
docsRepo: 'vuejs/vuepress',
// 假如文档不是放在仓库的根目录下:
docsDir: 'docs',
// 假如文档放在一个特定的分支下:
docsBranch: 'master',
// 默认是 false, 设置为 true 来启用
editLinks: true,
// 默认为 "Edit this page"
editLinkText: '帮助我们改善此页面!'
}
}

静态资源


静态资源都存放在public文件下


图片的使用


<img :src="$withBase('/frontend/prototype-chains.jpg')" alt="prototype-chains">


logo


// .vuepress/config.js
module.exports = {
themeConfig: {
logo: '/logo.png',
}
}

首页logo


// README.md
---
heroImage: /app.png
---

Markdown扩展


Emoji


你可以在这个列表 找到所有可用的 Emoji。


自定义容器




::: warning
这是一个警告
:::
::: danger
这是一个危险警告
:::
::: details
这是一个详情块,在 IE / Edge 中不生效
:::

你也可以自定义块中的标题:


::: danger STOP
危险区域,禁止通行
:::
::: details 点击查看代码
这是代码
:::

代码块中的语法高亮


VuePress 使用了 Prism 来为 markdown 中的代码块实现语法高亮。Prism 支持大量的编程语言,你需要做的只是在代码块的开始倒勾中附加一个有效的语言别名:


    ```
export default {
name: 'MyComponent',
// ...
}
```

在 Prism 的网站上查看 合法的语言列表


代码块中的行高亮


    ``` js {4}
export default {
data () {
return {
msg: 'Highlighted!'
}
}
}
```

除了单行以外,你也可指定多行,行数区间,或是两者都指定。



  • 行数区间: 例如 {5-8}, {3-10}, {10-17}

  • 多个单行: 例如 {4,7,9}

  • 行数区间与多个单行: 例如 {4,7-13,16,23-27,40}


行号


// config.js
module.exports = {
markdown: {
lineNumbers: true
}
}

导入代码段


<<< @/docs/.vuepress/code/test.js

使用vue组件


所有在 .vuepress/components 中找到的 *.vue 文件将会自动地被注册为全局的异步组件,如:


.
└─ .vuepress
└─ components
├─ demo-1.vue
├─ OtherComponent.vue
└─ Foo
└─ Bar.vue

你可以直接使用这些组件在任意的 Markdown 文件中(组件名是通过文件名取到的):


<demo-1/>
<OtherComponent/>
<Foo-Bar/>

插件


@vuepress/plugin-back-to-top


安装


yarn add -D @vuepress/plugin-back-to-top
# OR npm install -D @vuepress/plugin-back-to-top

使用


module.exports = {
plugins: ['@vuepress/back-to-top']
}

构建与部署


Github Pages 是面向用户、组织和项目开放的公共静态页面搭建托管服务,站点可以被免费托管在 Github 上,你可以选择使用 Github Pages 默 认提供的域名 github.io 或者自定义域名来发布站点。不仅免除了租服务器的麻烦,而且部署起来非常轻松。简而言之,在GitHub Pages上发布博客是非常好的选择。


创建两个仓库


1、amjanney.github.io,站点仓库,用来存放打包后的文件。


2、docs,用来放vuepress写的文档。


github.io会默认读取根目录下的index.html作为首页。所以我们要做的就是把打包后的vuepress文档上传到创建的名为<username>.github.io的仓库下。


image.png


仓库地址


github.com/amjanney/am…


github.com/amjanney/do…


发布


在docs下面有个deploy.sh文件,代码如下:


#!/usr/bin/env sh

# 确保脚本抛出遇到的错误
set -e

# 生成静态文件
npm run docs:build

# 进入生成的文件夹
cd docs/.vuepress/dist

# 如果是发布到自定义域名
# echo 'www.example.com' > CNAME

git init
git add -A
git commit -m 'deploy'

# 如果发布到 https://amjanney.github.io
git push -f https://github.com/amjanney/amjanney.github.io.git master

在package.json文件中配置命令


"deploy": "bash deploy.sh"

运行


npm run deploy

会执行deploy.sh中的命令,先打包vuepress文件,在docs/.vuepress/dist下面打包后的文件,cd到这个文件下面,通过git在上传到amjanney.github.io.git仓库,这时候访问amjanney.github.io/,文档就已经生效了。


每次更改了文件,就需要执行npm run deploy命令,更新文档。


当然这个过程可以集成一下,每次push代码到master的时候,自动出发npm run deploy过程。


更多部署方式移步链接


链接:https://juejin.cn/post/6990718623484477447

收起阅读 »

如何做前端单元测试

单元测试 什么是单元测试 单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证 需要访问数据库的测试不是单元测试 需要访问网络的测试不是单元测试 需要访问文件系统的测试不是单元测试 --- 修改代码的艺术 为什么要做单元测...
继续阅读 »

单元测试


什么是单元测试



单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证



需要访问数据库的测试不是单元测试

需要访问网络的测试不是单元测试

需要访问文件系统的测试不是单元测试

--- 修改代码的艺术

为什么要做单元测试



  1. 执行单元测试,就是为了证明这段代码的行为和我们期望的一致

  2. 进行充分的单元测试,是提高软件质量,降低开发成本的必由之路

  3. 在开发人员做出修改后进行可重复的单元测试可以避免产生那些令人不快的负作用


怎么去设计单元测试



  1. 理解这个单元原本要做什么(倒推出一个概要的规格说明(阅读那些程序代码和注释))

  2. 画出流程图

  3. 组织对这个概要规格说明的走读(Review),以确保对这个单元的说明没有基本的错误

  4. 设计单元测试

    在实践工作中,进行了完整计划的单元测试和编写实际的代码所花费的精力大致上是相同的





两个常用的单元测试方法论



  • TDD(Test-driven development):测试驱动开发

  • BDD(Behavior-driven development):行为驱动开发


前端与单元测试


如何对前端代码做单元测试


通常是针对函数、模块、对象进行测试


至少需要三类工具来进行单元测试:



  • *测试管理工具

  • *测试框架:就是运行测试的工具。通过它,可以为 JavaScript 应用添加测试,从而保证代码的质量

  • *断言库

  • 测试浏览器

  • 测试覆盖率统计工具


测试框架选择


Jasmine:Behavior-Drive development(BDD)风格的测试框架,在业内较为流行,功能很全面,自带 asssert、mock 功能


Qunit:该框架诞生之初是为了 jquery 的单元测试,后来独立出来不再依赖于 jquery 本身,但是其身上还是脱离不开 jquery 的影子


Mocha:node 社区大神 tj 的作品,可以在 node 和 browser 端使用,具有很强的灵活性,可以选择自己喜欢的断言库,选择测试结果的 report


Jest:来自于 facebook 出品的通用测试框架,Jest 是一个令人愉快的 JavaScript 测试框架,专注于简洁明快。他适用但不局限于使用以下技术的项目:Babel, TypeScript, Node, React, Angular, Vue


如何编写测试用例(Jest + Enzyme)


通常测试文件名与要测试的文件名相同,后缀为.test.js,所有测试文件默认放在__test__文件夹中


describe块之中,提供测试用例的四个函数:before()、after()、beforeEach()和 afterEach()。它们会在指定时间执行(如果不需要可以不写)


测试文件中应包括一个或多个describe, 每个 describe 中可以有一个或多个it,每个describe中可以有一个或多个expect.



describe 称为"测试套件"(test suite),it 块称为"测试用例"(test case)。



expect就是判断源码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误.


所有的测试都应该是确定的。 任何时候测试未改变的组件都应该产生相同的结果。 你需要确保你的快照测试与平台和其他不相干数据无关。


基础模板


describe('加法函数测试', () => {
before(() => {
// 在本区块的所有测试用例之前执行
});
after(() => {
// 在本区块的所有测试用例之后执行
});
beforeEach(() => {
// 在本区块的每个测试用例之前执行
});
afterEach(() => {
// 在本区块的每个测试用例之后执行
});

it('1加1应该等于2', () => {
expect(add(1, 1)).toBe(2);
});
it('2加2应该等于4', () => {
expect(add(2, 2)).toBe(42);
});
});

常用的测试


组件中的方法测试


it('changeCardType', () => {
let component = shallow(<Card />);
expect(component.instance().cardType).toBe('initCard');
component.instance().changeCardType('testCard');
expect(component.instance().cardType).toBe('testCard');
});

模拟事件测试


通过 Enzyme 可以在这个返回的 dom 对象上调用类似 jquery 的 api 进行一些查找操作,还可以调用 setProps 和 setState 来设置 props 和 state,也可以用 simulate 来模拟事件,触发事件后,去判断 props 上特定函数是否被调用,传参是否正确;组件状态是否发生预料之中的修改;某个 dom 节点是否存在是否符合期望


it('can save value and cancel', () => {
const value = 'edit';
const { wrapper, props } = setup({
editable: true,
});
wrapper.find('input').simulate('change', { target: { value } });
wrapper.setProps({ status: 'save' });
expect(props.onChange).toBeCalledWith(value);
});

使用 snapshot 进行 UI 测试


it('App -- snapshot', () => {
const renderedValue = renderer.create(<App />).toJSON();
expect(renderedValue).toMatchSnapshot();
});

真实用例分析(组件)


写一个单元测试你需要这样做



  1. 看代码,熟悉待测试模块的功能和作用

  2. 设计测试用例必须覆盖到组件的各种情况

  3. 对错误情况的测试


通常测试文件名与要测试的文件名相同,后缀为.test.js,所有测试文件默认放在test文件夹中,一般测试文件包含下列内容:



  • 全局设置:一些前置配置,mock 的全局或第三方方法、进行一些重复的组件初始化工作,,当多个测试用例有相同的初始化组件行为时,可以在这里进行挂载和销毁

  • UI 测试:为组件打快照,第一次运行测试命令会在目录下生成一个组件的 DOM 节点快照,在之后的测试命令中会与快照文件进行 diff 对照,避免在后面对组件进行了非期望的 UI 更改

  • 关键行为:验证组件的基本行为(如:Checkbox 组件的勾选行为)

  • 事件:测试各种事件的触发

  • 属性:测试传入不同属性值是否得到与期望一致的结果


accordion 组件


// accordion.test.tsx
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import toJSON from 'enzyme-to-json';
import JestMock from 'jest-mock';
import React from 'react';
import { Accordion } from '..';

Enzyme.configure({ adapter: new Adapter() }); // 需要根据项目的react版本来配置适配

describe('Accordion', () => {
// 测试套件,通过 describe 块来将测试分组
let onChange: JestMock.Mock<any, any>; // Jest 提供的mock 函数,擦除函数的实际实现、捕获对函数的调用
let wrapper: Enzyme.ReactWrapper;

beforeEach(() => {
// 在运行测试前做的一些准备工作
onChange = jest.fn();
wrapper = mount(
<Accordion onChange={onChange}>
<Accordion.Item name='one' header='one'>
two
</Accordion.Item>
<Accordion.Item name='two' header='two' disabled={true}>
two
</Accordion.Item>
<Accordion.Item name='three' header='three' showIcon={false}>
three
</Accordion.Item>
<Accordion.Item name='four' header='four' active={true} icons={['custom']}>
four
</Accordion.Item>
</Accordion>
);
});

afterEach(() => {
// 在运行测试后进行的一些整理工作
wrapper.unmount();
});

// UI快照测试,确保你的UI不会因意外改变
test('Test snapshot', () => {
// 测试用例,需要提供详细的测试用例描述
expect(toJSON(wrapper)).toMatchSnapshot();
});

// 事件测试
test('should trigger onChange', () => {
wrapper.find('.qtc-accordion-item-header').first().simulate('click');

expect(onChange.mock.calls.length).toBe(1);
expect(onChange.mock.calls[0][0]).toBe('one');
});

// 关键逻辑测试
//点击头部触发展开收起
test('should expand and collapse', () => {
wrapper.find('.qtc-accordion-item-header').at(2).simulate('click');

expect(wrapper.find('.qtc-accordion-item').at(2).hasClass('active')).toBeTruthy();
});
// 配置disabled时不可展开
test('should not trigger onChange when disabled', () => {
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(onChange.mock.calls.length).toBe(0);
});

// 对所有的属性配置进行测试
// 是否展示头部左侧图标
test('hide icon', () => {
expect(wrapper.find('.qtc-accordion-item-header').at(2).children().length).toBe(2);
});
// 自定义图标
test('custom icon', () => {
const customIcon = wrapper.find('.qtc-accordion-item-header').at(3).children().first();

expect(customIcon.getDOMNode().innerHTML).toBe('custom');
});
// 是否可展开多项
test('single expand', () => {
onChange = jest.fn();
wrapper = mount(
<Accordion multiple={false} onChange={onChange}>
<Accordion.Item name='1'>1</Accordion.Item>
<Accordion.Item name='2'>2</Accordion.Item>
</Accordion>
);

wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['2']));
});
test('mutiple expand', () => {
onChange = jest.fn();
wrapper = mount(
<Accordion multiple={true} onChange={onChange}>
<Accordion.Item name='1'>1</Accordion.Item>
<Accordion.Item name='2'>2</Accordion.Item>
</Accordion>
);

wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['1', '2']));
});
});

难点记录


对一些异步和延时的处理


使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试


test('the data is peanut butter', done => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}

fetchData(callback);
});

模拟 setTimeout


// 提取utils方法,封装一个sleep
export const sleep = async (timeout = 0) => {
await act(async () => {
await new Promise((resolve) => globalTimeout(resolve, timeout));
});
};

// 测试用例中调用
it('测试用例', async () => {
doSomething();
await sleep(1000);
doSomething();
})

mock 组件内系统函数的返回结果


对于组件内调用了 document 上的方法,可以通过 mock 指定方法的返回值,来保证一致性


const getBoundingClientRectMock = jest.spyOn(
HTMLHeadingElement.prototype,
'getBoundingClientRect',
);

beforeAll(() => {
getBoundingClientRectMock.mockReturnValue({
width: 100,
height: 100,
top: 1000,
} as DOMRect);
});

afterAll(() => {
getBoundingClientRectMock.mockRestore();
});

直接调用组件方法


通过 wrapper.instance()获取组件实例,再调用组件内方法,如:wrapper.instance().handleScroll()
测试系统方法的调用


const scrollToSpy = jest.spyOn(window, 'scrollTo');

const calls = scrollToSpy.mock.calls.length;

expect(scrollToSpy.mock.calls.length).toBeGreaterThan(calls);

使用属性匹配器代替时间


当快照有时间时,通过属性匹配器可以在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值


it('will check the matchers and pass', () => {
const user = {
createdAt: new Date(),
id: Math.floor(Math.random() * 20),
name: 'LeBron James',
};

expect(user).toMatchSnapshot({
createdAt: expect.any(Date),
id: expect.any(Number),
});
});

附录


JEST 语法


匹配器


expect:返回一个'期望‘的对象


toBe:使用 object.is 去判断相等


toEqual:递归检测对象或数组的每个字段


not:测试相反的匹配


真值


toBeNull:只匹配 null


toBeUndefined:只匹配 undefined


toBeDefined:与 toBeUndefined 相反


toBeTruthy:匹配任何 if 语句为真


toBeFalsy:匹配任务 if 语句为假


数字


toBeGreaterThan:大于


toBeGreaterThanOrEqual:大于等于


toBeLessThan:小于


toBeLessThanOrEqual:小于等于


toBeCloseTo:比较浮点数相等


字符串


toMatch:匹配字符串


Array


toContain:检测一个数组或可迭代对象是否包含某个特定项


例外


toThrow:测试某函数在调用时是否抛出了错误


自定义匹配器


// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();

// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();

测试异步代码


回调


默认情况下,一旦到达运行上下文底部 Jest 测试立即结束,使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试。


test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}

fetchData(callback);
});

Promises


为你的测试返回一个 Promise,Jest 会等待 Promise 的 resove 状态,如果 Promist 被拒绝,则测试将自动失败


test('the data is peanut butter', () => {
return fetchData().then((data) => {
expect(data).toBe('peanut butter');
});
});

如果期望 Promise 被 Reject,则需要使用 .catch 方法。 请确保添加 expect.assertions 来验证一定数量的断言被调用。 否则,一个 fulfilled 状态的 Promise 不会让测试用例失败


test('the fetch fails with an error', () => {
expect.assertions(1);
return fetchData().catch(e => expect(e).toMatch('error'));
});

.resolves/.rejects


test('the data is peanut butter', () => {
return expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', () => {
return expect(fetchData()).rejects.toMatch('error');
});

Async/Await


写异步测试用例时,可以再传递给 test 的函数前面加上 async。


安装和移除


为多次测试重复设置:beforeEach、afterEach 来为多次测试重复设置的工作


一次性设置:beforeAll、afterAll 在文件的开头做一次设置


作用域:可以通过 describe 块将测试分组,before 和 after 的块在 describe 块内部时,则只适用于该 describe 块内的测试


模拟函数


Mock 函数允许你测试代码之间的连接——实现方式包括:擦除函数的实际实现、捕获对函数的调用(以及在这些调用中传递的参数)、在使用 new 实例化时捕获构造函数的实例、允许测试时配置返回值。


两种方法可以模拟函数:1.在测试代码中创建一个 mock 函数,2.编写一个手动 mock 来覆盖模块依赖


mock 函数


const mockCallback = jest.fn((x) => 42 + x);
forEach([0, 1], mockCallback);

// 此 mock 函数被调用了两次
expect(mockCallback.mock.calls.length).toBe(2);

// 第一次调用函数时的第一个参数是 0
expect(mockCallback.mock.calls[0][0]).toBe(0);

// 第二次调用函数时的第一个参数是 1
expect(mockCallback.mock.calls[1][0]).toBe(1);

// 第一次函数调用的返回值是 42
expect(mockCallback.mock.results[0].value).toBe(42);

.mock 属性


所有的 mokc 函数都有这个特殊的.mock 属性,它保存了关于此函数如何被调用、调用时的返回值的信息。.mock 属性还追踪每次调用时的 this 的值,所以我们同样可以检查 this


// 这个函数被实例化两次
expect(someMockFunction.mock.instances.length).toBe(2);

// 这个函数被第一次实例化返回的对象中,有一个 name 属性,且被设置为了 'test’
expect(someMockFunction.mock.instances[0].name).toEqual('test');

Mock 的返回值


const myMock = jest.fn();
console.log(myMock());
// > undefined

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true

模拟模块


可以 用 jest.mock(...)函数自动模拟 axios 模块,一旦模拟模块,我们可为.get 提供一个 mockResolveValue,它会返回假数据用于测试


// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
const users = [{ name: 'Bob' }];
const resp = { data: users };
axios.get.mockResolvedValue(resp);

// or you could use the following depending on your use case:
// axios.get.mockImplementation(() => Promise.resolve(resp))

return Users.all().then((data) => expect(data).toEqual(users));
});

Mock 实现


用 mock 函数替换指定返回值:jest.fn(cb => cb(null, true))


用 mockImplementation 根据别的模块定义默认的 mock 函数实现:jest.mock('../foo'); const foo = require('../foo');foo.mockImplementation(() => 42);


当你需要模拟某个函数调用返回不同结果时,请使用 mockImplementationOnce 方法


.mockReturnThis()函数来支持链式调用


Mock 名称


可以为你的 Mock 函数命名,该名字会替代 jest.fn() 在单元测试的错误输出中出现。 用这个方法你就可以在单元测试输出日志中快速找到你定义的 Mock 函数


const myMockFn = jest
.fn()
.mockReturnValue('default')
.mockImplementation((scalar) => 42 + scalar)
.mockName('add42');

快照测试


当要确保你的 UI 不会又意外的改变时,快照测试是非常有用的工具;典型的做法是在渲染了 UI 组件之后,保存一个快照文件, 检测他是否与保存在单元测试旁的快照文件相匹配。 若两个快照不匹配,测试将失败:有可能做了意外的更改,或者 UI 组件已经更新到了新版本。


快照文件应该和项目代码一起提交并做代码评审


更新快照


jest --updateSnapshot/jest -u,这将为所有失败的快照测试重新生成快照文件。 如果我们无意间产生了 Bug 导致快照测试失败,应该先修复这些 Bug,再生成快照文件;只重新生成一部分的快照文件,你可以使用--testNamePattern 来正则匹配想要生成的快照名字


属性匹配器


项目中常常会有不定值字段生成(例如 IDs 和 Dates),针对这些情况,Jest 允许为任何属性提供匹配器(非对称匹配器)。 在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值


it('will check the matchers and pass', () => {
const user = {
createdAt: new Date(),
id: Math.floor(Math.random() * 20),
name: 'LeBron James',
};

expect(user).toMatchSnapshot({
createdAt: expect.any(Date),
id: expect.any(Number),
});
});

覆盖率


Jest 还提供了生成测试覆盖率报告的命令,只需要添加上 --coverage 这个参数即可生成,再加上--colors 可根据覆盖率生成不同颜色的报告(<50%红色,50%~80%黄色, ≥80%绿色)



  • % Stmts 是语句覆盖率(statement coverage):是否每个语句都执行了

  • % Branch 分支覆盖率(branch coverage):是否每个分支代码块都执行了(if, ||, ? : )

  • % Funcs 函数覆盖率(function coverage):是否每个函数都调用了

  • % Lines 行覆盖率(line coverage):是否每一行都执行了


Enzyme


nzyme 来自 airbnb 公司,是一个用于 React 的 JavaScript 测试工具,方便你判断、操纵和历遍 React Components 输出。Enzyme 的 API 通过模仿 jQuery 的 API ,使得 DOM 操作和历遍很灵活、直观。Enzyme 兼容所有的主要测试运行器和判断库。


安装与配置



  • npm install --save-dev enzyme

  • 安装 Enzyme Adapter 来对应 React 的版本 npm install --save-dev enzyme-adapter-react-16


渲染方式


shallow 浅渲染


返回组件的浅渲染,对官方 shallow rendering 进行封装。浅渲染 作用就是:它仅仅会渲染至虚拟 dom,不会返回真实的 dom 节点,这个对测试性能有极大的提升。shallow 只渲染当前组件,只能能对当前组件做断言


render 静态渲染


将 React 组件渲染成静态的 HTML 字符串,然后使用 Cheerio 这个库解析这段字符串,并返回一个 Cheerio 的实例对象,可以用来分析组件的 html 结构,对于 snapshot 使用 render 比较合适


mount 完全渲染


将组件渲染加载成一个真实的 DOM 节点,用来测试 DOM API 的交互和组件的生命周期,用到了 jsdom 来模拟浏览器环境


常用 API


.simulate(event, mock):用来模拟事件触发,event 为事件名称,mock 为一个 event object


.instance():返回测试组件的实例


.find(selector):根据选择器查找节点,selector 可以是 CSS 中的选择器,也可以是组件的构造函数,以及组件的 display name 等


.get(index):返回指定位置的子组件的 DOM 节点


.at(index):返回指定位置的子组件


.first():返回第一个子组件


.last():返回最后一个子组件


.type():返回当前组件的类型


.contains(nodeOrNodes):当前对象是否包含参数重点 node,参数类型为 react 对象或对象数组


.text():返回当前组件的文本内容


.html():返回当前组件的 HTML 代码形式


.props():返回根组件的所有属性


.prop(key):返回根组件的指定属性


.state([key]):返回根组件的状态


.setState(nextState):设置根组件的状态


.setProps(nextProps):设置根组件的属性



链接:https://juejin.cn/post/6990655486659919902

收起阅读 »

老掉牙之前端组件化

组件化已经无处不在。可能每个人一张嘴都是组件化模块化。 这个时候我们能否认真回想一下,自己的组件,真的是组件化了吗? 怎样的组件化才算比较好的组件化? 根据客观事实(主要是主观臆想),浅谈一下前端的组件化。 1、组件化的使用背景 业务的迭代和堆积 1、单个文件...
继续阅读 »

组件化已经无处不在。可能每个人一张嘴都是组件化模块化。

这个时候我们能否认真回想一下,自己的组件,真的是组件化了吗?

怎样的组件化才算比较好的组件化?

根据客观事实(主要是主观臆想),浅谈一下前端的组件化。


1、组件化的使用背景


业务的迭代和堆积


1、单个文件有成千上万行代码,可读性非常差,维护也不方便

2、有大量重复的代码,相同或者类似的功能实现了很多遍

3、新功能的开发成本巨大

4、不敢重构,牵一发而动全身


场景的多样化


1、不同的项目,类似的场景

2、相同的项目,越来越多的场景


背景和场景都有了。

如何判断你的代码质量如何?

一个比较直观(其实也是我的主观)的判断就是:2年后我是否还能轻易维护或者复用你的代码。

如果举步维艰,那我们应该好好想想,什么才是组件化?


2、组件化的定义和特性


(蹩脚的)定义


组件化 就是将UI、样式以及其实现的比较完整的功能作为独立的整体,

无关业务,

无论将这个整体放在哪里去使用,

它都具有一样的功能和UI,

从而达到复用的效果,

这种体系化的思想就是组件化。


特性——高内聚,低耦合


一个组件中包含了完整视图结构,样式表以及交互逻辑。

它是封闭的结构,对外提供属性的输入用来满足用户的多样化需求,

对内自己管理内部状态来满足自己的交互需求,

一言蔽之就是:高内聚,低耦合。


组件化的目的


减少重复造轮子(虽然造轮子是避免不了的事)、反复修轮胎(疲于奔命迭代维护组件)的频率,

增加代码复用性和灵活性,提高系统设计,从而提高开发效率。

说完组件化的基本定义和特性,接下来就说说组件化的分类吧。


3、组件的分类


分类的形式可能有多种和多角度,我这里按自己的日常(react技术栈)使用分一下。


函数组件和类组件


1、函数组件的写法要比类组件简洁

2、类组件比函数组件功能更加强大

类组件可以维护自身的状态变量,还有不同的生命周期方法,

可以让开发者能够在组件的不同阶段(挂载、更新、卸载),

对组件做更多的控制。

ps:自从hooks出来以后, 函数组件也能实现生命周期等更多骚操作了。


自己的使用原则:

如果功能相对简单简洁,就是用函数组件;

功能丰富多样,相对复杂就使用类组件。

界限不是特别清晰,但是大的基本原则是这个,仅供参考。


展示型组件和容器型组件


1、展示型组件像个父亲(父爱如山,一动不动,他真的不动!)

不用理会数据是怎么来到哪里去,

它只管兵来将挡,水来土掩,

你给我什么数据,就就拿它来渲染成相应的UI即可。


2、容器型组件则像个老师。

他需要知道如何获取学生(子组件)所需数据,

以及这些数据的处理逻辑,

并把数据和使用方法(处理逻辑)通过props提供给学生(子组件)使用。

ps:容器型组件一般是有状态组件,因为它们需要管理页面所需数据。


无状态组件和有状态组件


1、无状态组件内部不维护自身的state(因为它根本就没使用),

只根据外部组件传入的props返回待渲染的元素(传说中的饭来张口,衣来伸手?)。


2、有状态组件维护自身状态的变化,

并且根据外部组件传入的props和自身的state,

共同决定最终渲染的元素(自给自足,别人也来者不拒)


高阶组件


任性,就是不说,自己百度谷歌一下...

说完分类,说说使用了组件化以后有啥好处。


4、组件化的价值


业务价值


1、组件与具体场景或业务解耦,提升开发效率与降低风险,促进业务安全、快速迭代

2、提高了组件的复用和可移植,减少开发人力

3、方便测试模拟接口数据

4、便于堆积木般快速组合不同的场景和业务


技术价值


1、组件与框架解耦,去中心化的开发,这背后其实是一种前端微服务化的思想;

2、页面资源可以动态按需加载,提升性能;

3、组件可持续,可自由组合,提升开发效率;


O了,说得组件化这么好,那我们设计组件前,应该思考什么问题呢?


5、开发组件前的灵魂拷问?


组件应该如何划分,划分的粒度标准是什么?


组件划分的依据通常是业务逻辑和功能,

一段相对完整且完备的功能逻辑就是划分的一个界限。
当然,你还要考虑各组件之间的关系是否明确以及组件的可复用度等等。


这个组件还能再减吗,它还能减少不必要的代码和依赖吗?


越简单的组件,往往具备越容易复用的特性。
你看各大知名UI库组件库,
他们设计出来的轮子是不是几乎都差不多?
都是按钮,弹窗,提示框等等?
而那些看起来功能超级丰富的组件,往往使用的场景反而很少?


此组件是不是渣男?是不是到处去破坏别的组件,入侵其他组件却挥一挥衣袖,留下了一堆云彩?


如果一个组件的封装性不够好,或者实现自身的越界操作,

就可能对自身之外造成了侵入,这种情况应该尽量避免。

确保组件的生命周期能够对其影响进行有效的管理(如destroy后不留痕迹)。

举个栗子: 如果你的组件触发了鼠标滚轮事件,一滚轮就打断点,或者不断的加数据到内存中。

直到组件被销毁了,还没有去掉这个事件的处理,那就可能导致内存泄漏等等情况了,渣男...


是否便于拔插,就是来去自如的意思,复用方便,删除随便?


1、组件设计需要考虑需要适用的不同场景,

在组件设计时进行必要的兼容。

2、各组件之前以组合的关系互相配合,

也是对功能需求的模块化抽象,

当需求变化时可以将实现以模块粒度进行调整。

3、设计组件时要想想如何快速接入,快速删除,但是又不影响别的组件和业务。


6、组件化的设计原则和标准


不说了,打字打累了,直接上图 (有图有真相)


PS:这个标准和原则来源于网络(图是我的图),我看到的时候表示默默认同,记下来了,再截图出来。如果侵权,请告诉我删除。


组件化,你今天想好了吗?


链接:https://juejin.cn/post/6926726194054365192

收起阅读 »

防抖和节流知多少

防抖 在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新再等n秒在执行回调。 例子 //模拟一段ajax请求 function ajax(content) { console.log('ajax request ' + content) } l...
继续阅读 »

防抖


在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新再等n秒在执行回调。


例子


//模拟一段ajax请求
function ajax(content) {
console.log('ajax request ' + content)
}

let inputa = document.getElementById('unDebounce')

inputa.addEventListener('keyup', function (e) {
ajax(e.target.value)
})

看一下运行结果:
1111.gif


可以看到,我们只要按下键盘,就会触发这次ajax请求。不仅从资源上来说是很浪费的行为,而且实际应用中,用户也是输出完整的字符后,才会请求。下面我们优化一下:


//模拟一段ajax请求

function ajax(content) {
console.log('ajax request' + content)
}
function debounce(fn, delay) {
let timer;
return function () {
let context = this;
const args = [...arguments];
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
};
}
let dedebounceajax = debounce(ajax,1000);
var inputs = document.getElementById('inputs')
inputs.addEventListener("keyup",(e)=>{
dedebounceajax(e.target.value)
})

看一下运行结果:


2222.gif


可以看到,我们加入了防抖以后,当你在频繁的输入时,并不会发送请求,只有当你在指定间隔内没有输入时,才会执行函数。如果停止输入但是在指定间隔内又输入,会重新触发计时。


节流


规定在一个单位时间内,只能触发一次函数。
假设你点击一个按钮规定了5秒生效,不管你在5秒内点击了按钮多少次,5秒只会生效一次。


例子


// 时间戳箭头函数版本 节流函数

function throttle(func, wait) {
let timer = 0;
return (...rest) => {
let now = Date.now();
let that = this;
if (now > timer + delay) {
fn.apply(that, rest);
timer = now;
}
};
}

// 定时器版本 节流函数

function throttle(func, wait) {
let timeout;
return function() {
let context = this;
let args = arguments;
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
func.apply(context, args)
}, wait)
}

}
}

let throttleAjax = throttle(ajax, 1000)
let inputc = document.getElementById('throttle')
inputc.addEventListener('keyup', function(e) {
throttleAjax(e.target.value)
})

复制代码

看一下运行结果:


3333.gif


可以看到,我们在不断输入时,ajax会按照我们设定的时间,每1s执行一次。


总结



  • 函数防抖和函数节流都是防止某一时间频繁触发,但是这两兄弟之间的原理却不一样。

  • 函数防抖是某一段时间内只执行一次,而函数节流是间隔时间执行。


应用场景


防抖应用场景



  • input框搜索,用户在不断输入值时,用防抖来节约请求资源。

  • window触发resize的时候,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次。


节流应用场景



  • 鼠标不断点击触发,mousedown(单位时间内只触发一次)

  • 监听滚动事件,比如是否滑到底部自动加载更多,用throttle来判断


链接:https://juejin.cn/post/6989861117187063822

收起阅读 »

每个前端都需要知道这些面向未来的CSS技术

写在前面 前端技术日新月异,我们需要不断学习来更新自己的前端知识并运用到自己的项目中。这次笔者整理一些未来普及或者现在同学们可能已经用到的CSS特性,包括SVG图标、滚动特性、CSS自定义属性、CSS现代伪类 、JS in CSS、Web Layout、混合模...
继续阅读 »

写在前面


前端技术日新月异,我们需要不断学习来更新自己的前端知识并运用到自己的项目中。这次笔者整理一些未来普及或者现在同学们可能已经用到的CSS特性,包括SVG图标、滚动特性、CSS自定义属性、CSS现代伪类 、JS in CSS、Web Layout、混合模式和滤镜、CSS计数器等等。


滚动特性


能用CSS实现的就不用麻烦JavaScript文章提及到滚动捕捉的特性,更多有关于容器滚动方面的CSS新特性其实还有有很多个,比如:



  • 自定义滚动条的外观

  • scroll-behavior指容容器滚动行为,让滚动效果更丝滑

  • overscroll-behavior优化滚动边界,特别是可以帮助我们滚动的穿透


自定义滚动条的外观


默认的window外观和mac外观


windows
image.png
mac


image.png


在CSS中,我们可以使用-webkit-scrollbar来自定义滚动条的外观。该属性提供了七个伪元素:



  • ::-webkit-scrollbar:整个滚动条

  • ::-webkit-scrollbar-button:滚动条上的按钮(下下箭头)

  • ::-webkit-scrollbar-thumb:滚动条上的滚动滑块

  • ::-webkit-scrollbar-track:滚动条轨道

  • ::-webkit-scrollbar-track-piece:滚动条没有滑块的轨道部分

  • ::-webkit-scrollbar-corner:当同时有垂直和水平滚动条时交汇的部分

  • ::-webkit-resizer:某些元素的交汇部分的部分样式(类似textarea的可拖动按钮)


html {
--maxWidth:1284px;
scrollbar-color: linear-gradient(to bottom,#ff8a00,#da1b60);
scrollbar-width: 30px;
background: #100e17;
color: #fff;
overflow-x: hidden
}

html::-webkit-scrollbar {
width: 30px;
height: 30px
}

html::-webkit-scrollbar-thumb {
background: -webkit-gradient(linear,left top,left bottom,from(#ff8a00),to(#da1b60));
background: linear-gradient(to bottom,#ff8a00,#da1b60);
border-radius: 30px;
-webkit-box-shadow: inset 2px 2px 2px rgba(255,255,255,.25),inset -2px -2px 2px rgba(0,0,0,.25);
box-shadow: inset 2px 2px 2px rgba(255,255,255,.25),inset -2px -2px 2px rgba(0,0,0,.25)
}

html::-webkit-scrollbar-track {
background: linear-gradient(to right,#201c29,#201c29 1px,#100e17 1px,#100e17)
}

通过这几个伪元素,可以实现你自己喜欢的滚动条外观效果,比如下面这个示例:


image.png


完整演示


css自定义属性


你大概已经听说过CSS自定义属性,也被称为 CSS 变量,估计熟悉SCSS、LESS就会很快上手,概念大同小异,都是让我们的CSS变得可维护,目前Edge最新版都已经支持这个特性了,这说明现在 CSS 自定义属性已经能用在实际项目中了,相信不久以后开发者们将大大依赖这个特性。但还请在使用之前请先检查一下本文附录中 Postcss 对于 CSS 自定义属性的支持情况,以便做好兼容。


什么是自定义属性呢?简单来说就是一种开发者可以自主命名和使用的 CSS 属性。浏览器在处理像 color 、position 这样的属性时,需要接收特定的属性值,而自定义属性,在开发者赋予它属性值之前,它是没有意义的。所以要怎么给 CSS 自定义属性赋值呢?这倒和习惯无异


.foo {
color: red;
--theme-color: gray;
}

自定义元素的定义由 -- 开头,这样浏览器能够区分自定义属性和原生属性,从而将它俩分开处理。假如只是定义了一个自定义元素和它的属性值,浏览器是不会做出反应的。如上面的代码, .foo 的字体颜色由 color 决定,但 --theme-color.foo 没有作用。


你可以用 CSS 自定义元素存储任意有效的 CSS 属性值


.foo {
--theme-color: blue;
--spacer-width: 8px;
--favorite-number: 3;
--greeting: "Hey, what's up?";
--reusable-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.85);
}

使用


假如自定义属性只能用于设值,那也太没用了点。至少,浏览器得能获取到它们的属性值。


使用 var() 方法就能实现:


.button {
background-color: var(--theme-color);
}

下面这段代码中,我们将 .buttonbackground-color 属性值赋值为 --theme-color 的值。这例子看起来自定义属性也没什么了不起的嘛,但这是一个硬编码的情况。你有没有意识到,--theme-color 的属性值是可以用在任意选择器和属性上的呢?这可就厉害了。


.button {
background-color: var(--theme-color);
}

.title {
color: var(--theme-color);
}

.image-grid > .image {
border-color: var(--theme-color);
}

缺省值


如果开发者并没有定义过 --theme-color 这个变量呢?var() 可以接收第二个参数作为缺省值:


.button {
background-color: var(--theme-color, gray);
}


注意:如果你想把另一个自定义属性作为缺省值,语法应该是 background-color: var(--theme-color, var(--fallback-color))



传参数时总是传入一个缺省值是一个好习惯,特别是在构建 web components 的时候。为了让你的页面在不支持自定义属性的浏览器上正常显示,别忘了加上兼容代码:


.button {
background-color: gray;
background-color: var(--theme-color, gray);
}

CSS现代伪类


这些最新的伪类特性,我们也需要知道。
image.png


使用 :is() 减少重复


你可以使用 :is() 伪类来删除选择器列表中的重复项。


/* BEFORE */
.embed .save-button:hover,
.attachment .save-button:hover {
opacity: 1;
}

/* AFTER */
:is(.embed, .attachment) .save-button:hover {
opacity: 1;
}

此功能主要在未处理的标准CSS代码中有用。如果使用Sass或类似的CSS预处理程序,则可能更喜欢嵌套。


注意:浏览器还支持非标准的 :-webkit-any() 和 :-moz-any() 伪类,它们与 :is() 相似,但限制更多。WebKit在2015年弃用了 :-webkit-any() ,Mozilla已将Firefox的用户代理样式表更新为使用 :is() 而不是 :-moz-any()


使用 :where() 来保持低特殊性


:where() 伪类与 :is() 具有相同的语法和功能。它们之间的唯一区别是 :where() 不会增加整体选择器的特殊性(即某条CSS规则特殊性越高,它的样式越优先被采用)。



:where() 伪类及其任何参数都不对选择器的特殊性有所帮助,它的特殊性始终为零



此功能对于应易于覆盖的样式很有用。例如,基本样式表 sanitize.css 包含以下样式规则,如果缺少 <svg fill> 属性,该规则将设置默认的填充颜色:


svg:not([fill]) {
fill: currentColor;
}

由于其较高的特殊性(B = 1,C = 1),网站无法使用单个类选择器(B = 1)覆盖此声明,并且被迫添加 !important 或人为地提高选择器的特殊性(例如 .share- icon.share-icon)。


.share-icon {
fill: blue; /* 由于特殊性较低,因此不适用 */
}

CSS库和基础样式表可以通过用 :where() 包装它们的属性选择器来避免这个问题,以保持整个选择器的低特殊性(C=1)。


/* sanitize.css */
svg:where(:not([fill])) {
fill: currentColor;
}

/* author stylesheet */
.share-icon {
fill: blue; /* 由于特殊性较高,适用 */
}

其它新伪类特性有情趣同学可以按照导图查阅一下相关文档资料。


完整演示


JS in CSS


前面提到过,使用CSS自定义属性的时候,可以通过JavaScript来操作自定义属性的值。其实还可以更强大一点,如果你对CSS Houdini熟悉的话,可以借助其特性,直接在CSS的代码中来操作CSS自定义属性


:root {
--property: document.write('hello world!');
}

window.onload = () => {
const doc = window.getComputedStyle(document.documentElement);
const cssProp = doc.getPropertyValue('--property');
new Function((cssProp))();
}

完整演示


Web layout


对于Web布局而言,前端就一直在探讨这方面的最优方式。早期的table布局,接着的floatposition相关的布局,多列布局,Flexbox布局和Grid布局等。Flexbox和Grid的出现,Web布局的灵活性越来越高。


如图不依赖媒体查询实现自动计算


屏幕录制2021-07-27 下午3.17.46.gif


CSS Grid中提供了很多强大的特性,比如:



  • fr单位,可以很好的帮助我们来计算容器可用空间

  • repeat()函数,允许我们给网格多个列指定相同的值。它也接受两个值:重复的次娄和重复的值

  • minmax()函数,能够让我们用最简单的CSS控制网格轨道的大小,其包括一个最小值和一个最大值

  • auto-fillauto-fit,配合repeat()函数使用,可以用来替代重复次数,可以根据每列的宽度灵活的改变网格的列数

  • max-contentmin-content,可以根据单元格的内容来确定列的宽度

  • grid-suto-flow,可以更好的让CSS Grid布局时能自动排列


结合这些功能点,布局会变得更轻松。比如我们要实现一个响应式的布局,很多时候都会依赖于媒体查询(@media)来处理,事实上,有了CSS Grid Layout之后,这一切变得更为简单,不需要依赖任何媒体查询就可以很好的实现响应式的布局。特别是当今这个时代,要面对的终端设备只会增加不会减少,那么希望布局更容易的适配这些终端的布局,那么CSS Grid Layout将会起到很大的作用。


完整示例


Grid和flex都是面向未来的最佳布局方案。我们不应该探讨谁优谁劣,而是应该取长补短结合使用。


混合模式和滤镜


能用CSS实现的就不用麻烦JavaScript — Part2一文提到混合模式。CSS混合模式和滤镜主要是用来处理图片的。熟悉PS之类软件的同学很容易理解里面的属性。


屏幕录制2021-07-19 上午11.12.39.gif


完整代码演示


CSS计数器


CSS计数器其实涉及到三个属性:counter-incrementcounter-resetcounter()。一般情况都是配合CSS的伪元素::before::aftercontent一起使用。可以用来计数


屏幕录制2021-07-27 下午3.15.06.gif


完整演示


SVG图标


对于SVG而言,它是一套独立而又成熟的体系,也有自己的相关规范(Scalable Vecgtor Graphics 2),即 SVG2。虽然该规范已经存在很久了,但很多有关于SVG相关的特性在不同的浏览器中得到的支持度也是有所不一致的。特别是SVG中的渐变和滤镜相关的特性。不过,随着技术的革新,在Web的应用当中SVG的使用越来越多,特别是SVG 图标相关的方面的运用。




  • 最早通过<img>标签来引用图标(每个图标一个文件)




  • 为了节省请求,提出了Sprites的概念,即将多个图标合并在一起,使用一个图片文件,借助background相关的属性来实现图标




  • 图片毕竟是位图,面对多种设备终端,或者说更易于控制图标颜色和大小,开始在使用Icon Font来制作Web图标




  • 当然,字体图标是解决了不少问题,但每次针对不同的图标的使用,需要自定义字体,也要加载相应的字体文件,相应的也带了一定的问题,比如说跨域问题,字体加载问题




  • 随着SVG的支持力度越来越强,大家开始在思考SVG,使用SVG来制作图标。该技术能解决我们前面碰到的大部分问题,特别是在而对众多终端设备的时候,它的优势越发明显




  • SVG和img有点类似,我们也可以借助<symbol>标签和<use>标签,将所有的SVG图标拼接在一起,有点类似于Sprites的技术,只不过在此称为SVG Sprites




<!-- HTML -->
<svg width="0" height="0" display="none" xmlns="http://www.w3.org/2000/svg">
<symbol id="half-circle" viewBox="0 0 106 57">...</symbol>
<!-- .... -->
<symbol id="icon-burger" viewBox="0 0 24 24">...</symbol>
</svg>

SVG Sprites和img Sprites有所不同,SVG Sprites就是一些代码(类似于HTML一样),估计没有接触过的同学会问,SVG Sprites对应的代码怎么来获取呢?其实很简单,可以借助一些设计软件来完成,比如Sketch。当然也可以使用一些构建工具,比如说svg-sprite。有了这个之后,在该使用的地方,使用<use>标签,指定<symbol>中相应的id值即可,比如:


<svg class="icon-nav-articles" width="26px" height="26px">
<use xlink:href="#icon-nav-articles"></use>
</svg>

使用SVG的图标还有一优势,我们可以在CSS中直接通过代码来控制图标的颜色:


.site-header .main-nav .main-sections>li>a>svg {
// ...
fill: none;
stroke-width: 2;
stroke: #c2c2c2;
}
.site-header .main-nav:hover>ul>li:nth-child(1) svg {
stroke: #ff8a00;
}

image.png


完整演示


写在最后


以上列举都是CSS一些优秀的特性。还有很多,有时间再收集更多分享给大家。这些新特性在不同的浏览器中差异性是有所不同的。但这并不是阻碍我们去学习和探索的原因所在。我们应该及时去了解并运用到,才可以做到对项目精益求精。


链接:https://juejin.cn/post/6989513390636924936

收起阅读 »

React 中的一些 Router 必备知识点

前言 每次开发新页面的时候,都免不了要去设计一个新的 URL,也就是我们的路由。其实路由在设计的时候不仅仅是一个由几个简单词汇和斜杠分隔符组成的链接,偶尔也可以去考虑有没有更“优雅”的设计方式和技巧。而在这背后,路由和组件之间的协作关系是怎样的呢?于是我以 R...
继续阅读 »

前言


每次开发新页面的时候,都免不了要去设计一个新的 URL,也就是我们的路由。其实路由在设计的时候不仅仅是一个由几个简单词汇和斜杠分隔符组成的链接,偶尔也可以去考虑有没有更“优雅”的设计方式和技巧。而在这背后,路由和组件之间的协作关系是怎样的呢?于是我以 React 中的 Router 使用方法为例,整理了一些知识点小记和大家分享~


React-Router


基本用法


通常我们使用 React-Router 来实现 React 单页应用的路由控制,它通过管理 URL,实现组件的切换,进而呈现页面的切换效果。


其最基本用法如下:


import { Router, Route } from 'react-router';
render((
<Router>
<Route path="/" component={App}/>
</Router>
), document.getElementById('app'));

亦或是嵌套路由:


在 React-Router V4 版本之前可以直接嵌套,方法如下:


<Router>
<Route path="/" render={() => <div>外层</div>}>
<Route path="/in" render={() => <div>内层</div>} />
</Route>
</Router>

上面代码中,理论上,用户访问 /in 时,会先加载 <div>外层</div>,然后在它的内部再加载 <div>内层</div>


然而实际运行上述代码却发现它只渲染出了根目录中的内容。后续对比 React-Router 版本发现,是因为在 V4 版本中变更了其渲染逻辑,原因据说是为了践行 React 的组件化理念,不能让 Route 标签看起来只是一个标签(奇怪的知识又增加了)。


现在较新的版本中,可以使用 Render 方法实现嵌套。


<Route
path="/"
render={() => (
<div>
<Route
path="/"
render={() => <div>外层</div>}
/>
<Route
path="/in"
render={() => <div>内层</div>}
/>
<Route
path="/others"
render={() => <div>其他</div>}
/>
</div>
)}
/>

此时访问 /in 时,会将“外层”和“内层”一起展示出来,类似地,访问 /others 时,会将“外层”和“其他”一起展示出来。


图片


路由传参小 Tips


在实际开发中,往往在页面切换时需要传递一些参数,有些参数适合放在 Redux 中作为全局数据,或者通过上下文传递,比如业务的一些共享数据,但有些参数则适合放在 URL 中传递,比如页面类型或详情页中单据的唯一标识 id。在处理 URL 时,除了问号带参数的方式,React-Router 能帮我们做什么呢?在这其中,Route 组件的 path 属性便可用于指定路由的匹配规则。


场景 1



描述:就想让普普通通的 URL 带个平平无奇的参数



那么,接下来我们可以这样干:


Case A:路由参数


path="/book/:id"

我们可以用冒号 + 参数名字的方式,将想要传递的参数添加到 URL 上,此时,当参数名字(本 Case 中是 id)对应的值改变时,将被认为是不同 URL。


Case B:查询参数


path="/book"

如果想要在页面跳转的时候问号带参数,那么 path 可以直接设计成既定的样子,参数由跳转方拼接。
在跳转时,有两种形式带上参数。其一是在 Link 组件的 to 参数中通过配置字符串并用问号带参数,其二是 to 参数可以接受一个对象,其中可以在 search 字段中配置想要传递的参数。


<Link to="/book?id=111" />
// 或者
<Link to={{
pathname: '/book',
search: '?id=111',
}}/>

此时,假设当前页面 URL中的 id 由111 修改为 222 时,该路由对应的组件(在上述例子中就是 React-Route 配置时 path="/book" 对应的页面/组件 )会更新,即执行 componentDidUpdate 方法,但不会被卸载,也就是说,不会执行 componentDidMount 方法。


Case C:查询参数隐身式带法


path="/book"

path 依旧设计成既定的样子,而在跳转时,可以通过 Link 中的 state 将参数传递给对应路由的页面。


<Link to={{
pathname: '/book',
state: { id: 111 }
}}/>

但一定要注意的是,尽管这种方式下查询参数不会明文传递了,但此时页面刷新会导致参数丢失(存储在 state 中的通病),So,灰常不推荐~~(其实不想明文可以进行加密处理,但一般情况下敏感信息是不建议放在 URL 中传递的~)


场景 2



描述:编辑/详情页,想要共用一个页面,URL 由不同的参数区分,此时我们希望,参数必须为 edit、detail、add 中的 1 个,不然需要跳转到 404 Not Found 页面。



path='/book/:pageType(edit|detail|add)'

如果不加括号中的内容 (edit|detail|add),当传入错误的参数(比如用户误操作、随便拼接 URL 的情况),则页面不会被 404 拦截,而是继续走下去开始渲染页面或调用接口,但此时很有可能导致接口传参错误或页面出错。


场景 3



描述:新增页和编辑页辣么像,我的新增页也想和编辑/详情共用一个页面。但是新增页不需要 id,编辑/详情页需要 id,使用同一个页面怎么办?



path='/book/:pageType(edit|detail|add)/:id?'

别急,可以用 ? 来解决,它意味着 id 不是一个必要参数,可传可不传。


场景 4



描述:我的 id 只能是数字,不想要字符串怎么办?



path='/book/:id(\\\d+)'

此时 id 不是数字时,会跳转 404,被认为 URL 对应的页面找不到啦。


底层依赖


有了这么多场景,那 Router 是怎样实现的呢?其实它底层是依赖了 path-to-regexp 方法。


var pathToRegexp = require('path-to-regexp')
// pathToRegexp(path, keys, options)
// 示例
var keys = []
var re = pathToRegexp('/foo/:bar', keys)
// re = /^\/foo\/([^\/]+?)\/?$/i
// keys = [{ name: 'bar', prefix: '/', delimiter: '/', optional: false, repeat: false, pattern: '[^\\/]+?' }]


delimiter:重复参数的定界符,默认是 '/',可配置



一些其他常用的路由正则通配符:




  • ? 可选参数




  • * 匹配 0 次或多次




  • + 匹配 1 次或多次




如果忘记写参数名字,而只写了路由规则,比如下述代码中 /:foo 后面的参数:


var re = pathToRegexp('/:foo/(.*)', keys)
// 匹配除“\n”之外的任何字符
// keys = [{ name: 'foo', ... }, { name: 0, ...}]
re.exec('/test/route')
//=> ['/test/route', 'test', 'route']

它也会被正确解析,只不过在方法处理的内部,未命名的参数名会被替换成数组下标。


取路由参数


path 带的参数,可以通过 this.props.match 获取


例如:


// url 为 /book/:pageType(edit|detail|add)
const { match } = this.props;
const { pageType } = match.params;

由于有 #,# 之后的所有内容都会被认为是 hash 的一部分,window.location.search 是取不到问号带的参数的。


比如:aaa.bbb.com/book-center…


那么在 React-Router 中,问号带的参数,可以通过 this.props.location (官方墙推 👍)获取。个人理解是因为 React-Router 帮我们做了处理,通过路由和 hash 值(window.location.hash)做了解析的封装。


例如:


// url 为 /book?pageType=edit
const { location } = this.props;
const searchParams = location.search; // ?pageType=edit

实际打印 props 参数发现,this.props.history.location 也可以取到问号参数,但不建议使用,因为 React 的生命周期(componentWillReceiveProps、componentDidUpdate)可能使它变得不可靠。(原因可参考:blog.csdn.net/zrq1210/art…


在早期的 React-Router 2.0 版本是可以用 location.query.pageType 来获取参数的,但是 V4.0 去掉了(有人认为查询参数不是 URL 的一部分,有人认为现在有很多第三方库,交给开发者自己去解析会更好,有个对此讨论的 Issue,有兴趣的可以自行获取 😊 github.com/ReactTraini…


针对上一节中场景 1 的 Case C,查询参数隐身式带法时(从 state 里带过去的),在 this.props.location.state 里可以取到(不推荐不推荐不推荐,刷新会没~)


Switch


<div>
<Route
path="/router/:type"
render={() => <div>影像</div>}
/>
<Route
path="/router/book"
render={() => <div>图书</div>}
/>
</div>

如果 <Route /> 是平铺的(用 div 包裹是因为 Router 下只能有一个元素),输入 /router/book 则影像和图书都会被渲染出来,如果想要只精确渲染其中一个,则需要 Switch


<Switch>
  <Route
    path="/router/:type"
    render={() => <div>影像</div>}
  />
  <Route
    path="/router/book"
    render={() => <div>图书</div>}
  />
</Switch>

Switch 的意思便是精准的根据不同的 path 渲染不同 Route 下的组件。
但是,加了 Switch 之后路由匹配规则是从上到下执行,一旦发现匹配,就不再匹配其余的规则了。因此在使用的时候一定要“百般小心”。


上面代码中,用户访问 /router/book 时,不会触发第二个路由规则(不会 展示“图书”),因为它会匹配 /router/:type 这个规则。因此,带参数的路径一般要写在路由规则的底部。


路由的基本原理


路由做的事情:管控 URL 变化,改变浏览器中的地址。


Router 做的事情:URL 改变时,触发渲染,渲染对应的组件。


URL 有两种,一种不带 #,一种带 #,分别对应 Browse 模式和 Hash 模式。


一般单页应用中,改变 URL,但是不重新加载页面的方式有两类:


Case 1(会触发路由监听事件):点击 前进、后退,或者调用的 history.back( )、history.forward( )


Case 2(不会触发路由监听事件):组件中调用 history.push( ) 和 history.replace( )


于是参考 「源码解析 」这一次彻底弄懂 React-Router 路由原理 一文,针对上述两种 Case,以及这两种 Case 分别对应的两种模式,作出如下总结。


图片



图片来源:「源码解析 」这一次彻底弄懂 React-Router 路由原理



Browser 模式


Case 1:


URL 改变,触发路由的监听事件 popstate,then,监听事件的回调函数 handlePopState 在回调中触发 history 的 setState 方法,产生新的 location 对象。state 改变,通知 Router 组件更新 location 并通过 context 上下文传递,匹配出符合的 Route 组件,最后由 <Route /> 组件取出对应内容,传递给渲染页面,渲染更新。


/* 简化版的 handlePopState (监听事件的回调) */
const handlePopState = (event)=>{
     /* 获取当前location对象 */
    const location = getDOMLocation(event.state)
    const action = 'POP'
     /* transitionManager 处理路由转换 */
    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
        if (ok) {
          setState({ action, location })
        } else {
          revertPop(location)
        }
    })
}

Case 2:
以 history.push 为例,首先依据你要跳转的 path 创建一个新的 location 对象,然后通过 window.history.pushState (H5 提供的 API )方法改变浏览器当前路由(即当前的 url),最后通过 setState 方法通知 Router,触发组件更新。


const push = (path, state) => {
  const action = 'PUSH'
   /* 创建location对象 */
  const location = createLocation(path, state, createKey(), history.location)
   /* 确定是否能进行路由转换 */
   transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
... // 此处省略部分代码
   const href = createHref(location)
   const { key, state } = location
   if (canUseHistory) {
     /* 改变 url */
     globalHistory.pushState({ key, state }, null, href)
     if (forceRefresh) {
       window.location.href = href
     } else {
      /* 改变 react-router location对象, 创建更新环境 */
       setState({ action, location })
     }
   } else {
     window.location.href = href
   }
 })
}

Hash 模式


Case 1:


增加监听,当 URL 的 Hash 发生变化时,触发 hashChange 注册的回调,回调中去进行相类似的操作,进而展示不同的内容。


window.addEventListener('hashchange',function(e){
/* 监听改变 */
})

Case 2:
history.push 底层调用 window.location.hash 来改变路由。history.replace 底层是调用 window.location.replace 改变路由。然后 setState 通知改变。


从一些参考资料中显示,出于兼容性的考虑(H5 的方法 IE10 以下不兼容),路由系统内部将 Hash 模式作为创建 History 对象的默认方法。(此处若有疑议,欢迎指正~)


Dva/Router


在实际项目中发现,Link,Route 都是从 dva/router 中引进来的,那么,Dva 在这之中做了什么呢?


答案:貌似没有做特殊处理,Dva 在 React-Router 上做了上层封装,会默认输出 React-Router 接口。


我们对 Router 做过的一些处理


Case 1:


项目代码的 src 目录下,不管有多少文件夹,路由一般会放在同一个 router.js 文件中维护,但这样会导致页面太多时,文件内容会越来越长,不便于查找和修改。


因此我们可以做一些小改造,在 src 下的每个文件夹中,创建自己的路由配置文件,以便管理各自的路由。但这种情况下 React-Router 是不能识别的,于是我们写了一个 Plugin 放在 Webpack 中,目的是将各个文件夹下的路由汇总,并生成 router-config.js 文件。之后,将该文件中的内容解析成组件需要的相关内容。插件实现方式可了解本团队另一篇文章: 手把手带你入门 Webpack Plugin


Case 2:


路由的 Hash 模式虽然兼容性好,但是也存在一些问题:



  1. 对于 SEO、前端埋点不太友好,不容易区分路径

  2. 原有页面有锚点时,使用 Hash 模式会出现冲突


因此公司内部做了一次 Hash 路由转 Browser 路由的改造。


如原有链接为:aaa.bbb.com/book-center…


改造方案为:


通过新增以下配置代码去掉 #


import createHistory from 'history/createBrowserHistroy';
const app = dva({
history: createHistory({
basename: '/book-center',
}),
onError,
});

同时,为了避免用户访问旧页面出现 404 的情况,前端需要在 Redirect 中配置重定向以及在 Nginx 中配置旧的 Hash 页面转发。


Case 3:


在实际项目中,其实我们也会去考虑用户未授权时路由跳转、页面 404 时路由跳转等不同情况,以下 Case 和代码仅供读者参考~


<Switch>
{
getRoutes(match.path, routerData).map(item =>
(
// 用户未授权处理,AuthorizedRoute 为项目中自己实现的处理组件
<AuthorizedRoute
{...item}
redirectPath="/exception/403"
/>
)
)
}
// 默认跳转页面
<Redirect from="/" exact to="/list" />
// 页面 404 处理
<Route render={props => <NotFound {...props} />} />
</Switch>

参考链接


「源码解析 」这一次彻底弄懂react-router路由原理


react-router v4 路由规则解析


二级动态路由的解决方案


链接:https://juejin.cn/post/6989764387275800607

收起阅读 »

老生常谈的JavaScript闭包

老生常谈的闭包 很多观点参考于《你不知道的JavaScript》、《JavaScript忍者秘籍》,私信我,可发电子书呀。进入正文: 也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子!也许你觉得闭包平时用不到,但是每次面试你都得去准备这个方面内容!也...
继续阅读 »

老生常谈的闭包


很多观点参考于《你不知道的JavaScript》、《JavaScript忍者秘籍》,私信我,可发电子书呀。进入正文:


也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子!也许你觉得闭包平时用不到,但是每次面试你都得去准备这个方面内容!也许你不觉得这个功能有什么用,但是很多框架的功能都是基于闭包去实现的!


下面我们将目光聚焦到以下几个问题,来理解一下闭包:



  • 词法作用域

  • 闭包的形成

  • 闭包的概念

  • 闭包的常见形式

  • 闭包的作用


闭包与词法作用域


在《你不知道的JavaScript》书有一句原话:闭包是基于词法作用域书写代码所产生的自然结果。所以在知道闭包之前,得先理解什么是词法作用域。以前的文章有过介绍: 理解JavaScript的词法作用域(可以稍微的翻看一下)。如果不想看,也没关系。 接下来我们分析一段代码,去理解什么是词法作用域,以及闭包的形成。


var a = 100

function foo() {
var a = 10
function test() {
var b = 9
console.log(a + b)
}
return test
}

var func = foo()
func()

作用域分析


image


上图我们清晰的反应了作用域嵌套。



  • 其中全局变量func就是test函数的引用。

  • test定义虽然定义在foo包裹的作用域内,但运行在全局作用域内。

  • test里面执行a + b的时候,a变量的值等于10而不是等于100。说明变量a的查找跟test在哪里执行没有关系。

  • 这就是词法作用域,在书写阶段作用域嵌套就已经确定好了,跟函数在哪里运行没有关系。


闭包的形成


对上诉代码进行作用域分析之后我们不难得出一个结论:test函数不管在哪里执行,他永远都属于foo作用域下得一个标识符,所以test永远对foo作用域持有访问的权限


正常情况下,foo函数执行完毕后,js的垃圾回收机制就会对foo函数作用域进行销毁。但是由于test函数对foo的作用域持有引用,所以只要程序还在运行中,你就永远不会知道test会在哪里被调用。 每当test要执行的时候,都会去访问foo作用域下的a变量。所以垃圾回收机制在foo执行完毕之后,不会对foo作用域进行销毁。这就形成了闭包


闭包的常见形式


以上我们分析了,闭包是怎么形成的。也分析了一段典型的闭包代码。前言中我们有说过一句话也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子。接下来我们分析一下,闭包的常见形式。


以下代码就算你不了解闭包,你也写过。类似的:


computed: {
add() {
return function(num) {
return num + 1
}
}
},


vue中可接受参数的计算属性



function init() {
$(.name).click(function handleClickBtn() {
alert('click btn')
})
}


初始化函数中使用jq绑定事件



$.ajax({url:"/api/getName",success:function(result){
console.log(result)
}});


ajax请求数据



window.addEventListener('click', function() {

})


原生注册事件



可以发现当把函数当做值传递的时候,就会形成闭包。《你不知道的JavaScript》给出了总结: 如果将函数(访问它们各自的词法作用域)当作第一
级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、
Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使
用了回调函数,实际上就是在使用闭包!


闭包的作用


闭包的一大用处是回调函数。还有一个作用是封装私变量。在《JavaScript忍者秘籍》有专门章节对其进行详细讲解。 下面我们看看闭包如何封装私有变量


私有变量封装


场景:有一个函数foo, 统计其被调用的次数


var num = 0

function foo() {
// 次数加一
num = num + 1
return num
}

foo()
foo()
foo()

console.log(num)


全局变量num来统计foo调用次数,最大的坏处在于,你不知道程序运行到什么时候,num的值被篡改。如果能够将num变量私有化,外部不能随意更改就好了。



function baz() {
var num = 0

return function() {
num++
return num
}
}

var foo = baz()
foo()
foo()
let fooNum = foo()

console.log(fooNum)


通过闭包,num被私有化在baz作用域下,程序运行过程中,不能随意更改baz下的num值。



小结



  • 闭包是基于词法作用域书写代码产生的自然结果

  • 闭包经常出现在我们的代码里面,常见的是回调函数

  • 闭包作用很多,回调函数,私有化变量等等


作者:limbo
链接:https://juejin.cn/post/6989148728649072653

收起阅读 »

带你了解SSO登录过程

什么是单点登录? 单点登录(Single Sign On),简称为SSO,是比较流行的企业业务整合的解决方案之一。 SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。 上图为sso的登录方式,对比传统登录方式,sso只做...
继续阅读 »

什么是单点登录?



单点登录(Single Sign On),简称为SSO,是比较流行的企业业务整合的解决方案之一。 SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。




上图为sso的登录方式,对比传统登录方式,sso只做一次身份验证,而传统需要做多次登录。下图为传统登录方式。



登录类型



  1. 无登录状态。需要用户登录。

  2. 已登录app1,再次登录app1。(token有效)无需用户登录

  3. 已登录app1,登录app2。(有登录状态)无需用户登录


登录原理图


1. 无登录状态登录图,入下图:



2. 再次登录app1



3. 登录app2, 由于app1等中,中心服务sso已经生成了登录状态TGC,app2就不需要扫码登录。



链接:https://juejin.cn/post/6988752065501593636

收起阅读 »

JS 解决超出精度数字问题

一、js 最大安全数字是 Math.pow(2,53) - 1,超出这个数字相加会出现精度丢失问题,可通过将数字转换为字符串操作的思路处理,如下: // js 最大安全数字: Math.pow(2, 53)-1 let a = '12345644456545...
继续阅读 »

一、js 最大安全数字是 Math.pow(2,53) - 1,超出这个数字相加会出现精度丢失问题,可通过将数字转换为字符串操作的思路处理,如下:


// js 最大安全数字: Math.pow(2, 53)-1

let a = '123456444565456.889'
let b = '121231456.32'
// a + b = '123456565796913.209'

function addTwo(a, b) {
//1.比较两个数长度 然后短的一方前面补0
if (a.length > b.length) {
let arr = Array(a.length - b.length).fill(0);
b = arr.join('') + b
} else if (a.length < b.length) {
let arr = Array(b.length - a.length).fill(0);
a = arr.join('') + a
}

//2.反转两个数 (这里是因为人习惯从左往右加 而数字相加是从右到左 因此反转一下比较好理解)
a = a.split('').reverse();
b = b.split('').reverse();

//3.循环两个数组 并进行相加 如果和大于10 则 sign = 1,当前位置的值为(和)
let sign = 0;//标记 是否进位
let newVal = [];//用于存储最后的结果
for (let j = 0; j < a.length; j++) {
let val = a[j] / 1 + b[j] / 1 + sign; //除1是保证都为数字 这里也可以用Number()
if (val >= 10) {
sign = 1;
newVal.unshift(val % 10) //这里用unshift而不是push是因为可以省了使用reverse
} else {
sign = 0;
newVal.unshift(val)
}
}

// 最后一次相加需要向前补充一位数字 ‘1’
return sign && newVal.unshift(sign) && newVal.join('') || newVal.join('')
}


// 参考其他朋友的精简写法
function addTwo(a,b) {
let temp = 0
let res = ''
a = a.split('')
b = b.split('')
while(a.length || b.length || temp) {
temp += Number(a.pop() || 0) + Number(b.pop() || 0)
res = (temp) + res
temp = temp > 9
}
return res.replace(/^0+/g, '')
}

二、当涉及到带有小数部分相加时,对上面方法进行一次封装,完整实现如下:


let a = '123456444565456.889'
let b = '121231456.32'
// a + b = '123456565796913.209'

function addTwo(a = '0',b = '0', isHasDecimal=false) {
//1.比较两个数长度 然后短的一方前面补0
if (a.length > b.length) {
let arr = Array(a.length - b.length).fill(0);
b = isHasDecimal && (b + arr.join('')) || arr.join('') + b
} else if (a.length < b.length) {
let arr = Array(b.length - a.length).fill(0);
a = isHasDecimal && (a + arr.join('')) || arr.join('') + a
}

//2.反转两个数 (这里是因为人习惯从左往右加 而数字相加是从右到左 因此反转一下比较好理解)
a = a.split('').reverse();
b = b.split('').reverse();


//3.循环两个数组 并进行相加 如果和大于10 则 sign = 1,当前位置的值为(和)
let sign = 0;//标记 是否进位
let newVal = [];//用于存储最后的结果
for (let j = 0; j < a.length; j++) {
let val = a[j] / 1 + b[j] / 1 + sign; //除1是保证都为数字 这里也可以用Number()
if (val >= 10) {
sign = 1;
newVal.unshift(val % 10) //这里用unshift而不是push是因为可以省了使用reverse
} else {
sign = 0;
newVal.unshift(val)
}
}

// 最后一次相加需要向前补充一位数字 ‘1’
return sign && newVal.unshift(sign) && newVal.join('') || newVal.join('')
}

function add(a,b) {
let num1 = String(a).split('.')
let num2 = String(b).split('.')
let intSum = addTwo(num1[0], num2[0])
let res = intSum

if (num1.length>1 || num2.length > 1) {
let decimalSum = addTwo(num1[1], num2[1], true)

if (decimalSum.length > (num1[1]||'0').length && decimalSum.length > (num2[1]||'0').length) {
intSum = addTwo(intSum, decimalSum[0])
decimalSum = decimalSum.slice(1)
res = `${intSum}.${decimalSum}`
} else {
res = `${intSum}.${decimalSum}`
}
}
return res
}
console.log(add(a, b)) // 123456565796913.209
// console.log(add('325', '988')) // 1313

链接:https://juejin.cn/post/6988693462618996749

收起阅读 »

文件下载,搞懂这9种场景就够了(下)

六、附件形式下载在服务端下载的场景中,附件形式下载是一种比较常见的场景。在该场景下,我们通过设置 Content-Disposition 响应头来指示响应的内容以何种形式展示,是以内联(inline)的形式,还是以附件(attachment...
继续阅读 »

六、附件形式下载

在服务端下载的场景中,附件形式下载是一种比较常见的场景。在该场景下,我们通过设置 Content-Disposition 响应头来指示响应的内容以何种形式展示,是以内联(inline)的形式,还是以附件(attachment)的形式下载并保存到本地。

Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="mouth.png"

而在 HTTP 表单的场景下, Content-Disposition 也可以作为 multipart body 中的消息头:

Content-Disposition: form-data
Content-Disposition: form-data; name="fieldName"
Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"

第 1 个参数总是固定不变的 form-data;附加的参数不区分大小写,并且拥有参数值,参数名与参数值用等号(=)连接,参数值用双引号括起来。参数之间用分号(;)分隔。

了解完 Content-Disposition 的作用之后,我们来看一下如何实现以附件形式下载的功能。Koa 是一个简单易用的 Web 框架,它的特点是优雅、简洁、轻量、自由度高。所以我们选择它来搭建文件服务,并使用 @koa/router 中间件来处理路由:

// attachment/file-server.js
const fs = require("fs");
const path = require("path");
const Koa = require("koa");
const Router = require("@koa/router");

const app = new Koa();
const router = new Router();
const PORT = 3000;
const STATIC_PATH = path.join(__dirname, "./static/");

// http://localhost:3000/file?filename=mouth.png
router.get("/file", async (ctx, next) => {
const { filename } = ctx.query;
const filePath = STATIC_PATH + filename;
const fStats = fs.statSync(filePath);
ctx.set({
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename=${filename}`,
"Content-Length": fStats.size,
});
ctx.body = fs.createReadStream(filePath);
});

// 注册中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录
ctx.status = error.code === "ENOENT" ? 404 : 500;
ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差";
}
});
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});

以上的代码被保存在 attachment 目录下的 file-server.js 文件中,该目录下还有一个 static 子目录用于存放静态资源。目前 static 目录下包含以下 3 个 png 文件。

├── file-server.js
└── static
├── body.png
├── eyes.png
└── mouth.png

当你运行 node file-server.js 命令成功启动文件服务器之后,就可以通过正确的 URL 地址来下载 static 目录下的文件。比如在浏览器中打开 http://localhost:3000/file?filename=mouth.png 这个地址,你就会开始下载 mouth.png 文件。而如果指定的文件不存在的话,就会返回文件不存在。

Koa 内核很简洁,扩展功能都是通过中间件来实现。比如常用的路由、CORS、静态资源处理等功能都是通过中间件实现。因此要想掌握 Koa 这个框架,核心是掌握它的中间件机制。若你想深入了解 Koa 的话,可以阅读 如何更好地理解中间件和洋葱模型 这篇文章。

在编写 HTML 网页时,对于一些简单图片,通常会选择将图片内容直接内嵌在网页中,从而减少不必要的网络请求,但是图片数据是二进制数据,该怎么嵌入呢?绝大多数现代浏览器都支持一种名为 Data URLs 的特性,允许使用 Base64 对图片或其他文件的二进制数据进行编码,将其作为文本字符串嵌入网页中。所以文件也可以通过 Base64 的格式进行传输,接下来我们将介绍如何下载 Base64 格式的图片。

附件形式下载示例:attachment

github.com/semlinker/f…

七、base64 格式下载

Base64 是一种基于 64 个可打印字符来表示二进制数据的表示方法。由于 2⁶ = 64 ,所以每 6 个比特为一个单元,对应某个可打印字符。3 个字节有 24 个比特,对应于 4 个 base64 单元,即 3 个字节可由 4 个可打印字符来表示。相应的转换过程如下图所示:

Base64 常用在处理文本数据的场合,表示、传输、存储一些二进制数据,包括 MIME 的电子邮件及 XML 的一些复杂数据。 在 MIME 格式的电子邮件中,base64 可以用来将二进制的字节序列数据编码成 ASCII 字符序列构成的文本。使用时,在传输编码方式中指定 base64。使用的字符包括大小写拉丁字母各 26 个、数字 10 个、加号 + 和斜杠 /,共 64 个字符,等号 = 用来作为后缀用途。

Base64 的相关内容就先介绍到这,如果你想进一步了解 Base64 的话,可以阅读 一文读懂base64编码 这篇文章。下面我们来看一下具体实现代码:

7.1 前端代码

html

在以下 HTML 代码中,我们通过 select 元素来让用户选择要下载的图片。当用户切换不同的图片时,img#imgPreview 元素中显示的图片会随之发生变化。

<h3>base64 下载示例</h3>
<img id="imgPreview" src="./static/body.png" />
<select id="picSelect">
<option value="body">body.png</option>
<option value="eyes">eyes.png</option>
<option value="mouth">mouth.png</option>
</select>
<button onclick="download()">下载</button>

js

const picSelectEle = document.querySelector("#picSelect");
const imgPreviewEle = document.querySelector("#imgPreview");

picSelectEle.addEventListener("change", (event) => {
imgPreviewEle.src = "./static/" + picSelectEle.value + ".png";
});

const request = axios.create({
baseURL: "http://localhost:3000",
timeout: 60000,
});

async function download() {
const response = await request.get("/file", {
params: {
filename: picSelectEle.value + ".png",
},
});
if (response && response.data && response.data.code === 1) {
const fileData = response.data.data;
const { name, type, content } = fileData;
const imgBlob = base64ToBlob(content, type);
saveAs(imgBlob, name);
}
}

在用户选择好需要下载的图片并点击下载按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们利用 axios 实例的 get 方法发起 HTTP 请求来获取指定的图片。因为返回的是 base64 格式的图片,所以在调用 FileSaver 提供的 saveAs 方法前,我们需要将 base64 字符串转换成 blob 对象,该转换是通过以下的 base64ToBlob 函数来完成,该函数的具体实现如下所示:

function base64ToBlob(base64, mimeType) {
let bytes = window.atob(base64);
let ab = new ArrayBuffer(bytes.length);
let ia = new Uint8Array(ab);
for (let i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
return new Blob([ab], { type: mimeType });
}

7.2 服务端代码

// base64/file-server.js
const fs = require("fs");
const path = require("path");
const mime = require("mime");
const Koa = require("koa");
const cors = require("@koa/cors");
const Router = require("@koa/router");

const app = new Koa();
const router = new Router();
const PORT = 3000;
const STATIC_PATH = path.join(__dirname, "./static/");

router.get("/file", async (ctx, next) => {
const { filename } = ctx.query;
const filePath = STATIC_PATH + filename;
const fileBuffer = fs.readFileSync(filePath);
ctx.body = {
code: 1,
data: {
name: filename,
type: mime.getType(filename),
content: fileBuffer.toString("base64"),
},
};
});

// 注册中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
ctx.body = {
code: 0,
msg: "服务器开小差",
};
}
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});

在以上代码中,对图片进行 Base64 编码的操作是定义在 /file 路由对应的路由处理器中。当该服务器接收到客户端发起的文件下载请求,比如 GET /file?filename=body.png HTTP/1.1 时,就会从 ctx.query 对象上获取 filename 参数。该参数表示文件的名称,在获取到文件的名称之后,我们就可以拼接出文件的绝对路径,然后通过 Node.js 平台提供的 fs.readFileSync 方法读取文件的内容,该方法会返回一个 Buffer 对象。在成功读取文件的内容之后,我们会继续调用 Buffer 对象的 toString 方法对文件内容进行 Base64 编码,最终所下载的图片将以 Base64 格式返回到客户端。

base64 格式下载示例:base64

github.com/semlinker/f…

八、chunked 下载

分块传输编码主要应用于如下场景,即要传输大量的数据,但是在请求在没有被处理完之前响应的长度是无法获得的。例如,当需要用从数据库中查询获得的数据生成一个大的 HTML 表格的时候,或者需要传输大量的图片的时候。

要使用分块传输编码,则需要在响应头配置 Transfer-Encoding 字段,并设置它的值为 chunked 或 gzip, chunked

Transfer-Encoding: chunked
Transfer-Encoding: gzip, chunked

响应头 Transfer-Encoding 字段的值为 chunked,表示数据以一系列分块的形式进行发送。需要注意的是 Transfer-Encoding 和 Content-Length 这两个字段是互斥的,也就是说响应报文中这两个字段不能同时出现。下面我们来看一下分块传输的编码规则:

  • 每个分块包含分块长度和数据块两个部分;
  • 分块长度使用 16 进制数字表示,以 \r\n 结尾;
  • 数据块紧跟在分块长度后面,也使用 \r\n 结尾,但数据不包含 \r\n
  • 终止块是一个常规的分块,表示块的结束。不同之处在于其长度为 0,即 0\r\n\r\n

了解完分块传输的编码规则,我们来看如何利用分块传输编码实现文件下载。

8.1 前端代码

html5

<h3>chunked 下载示例</h3>
<button onclick="download()">下载</button>

js

const chunkedUrl = "http://localhost:3000/file?filename=file.txt";

function download() {
return fetch(chunkedUrl)
.then(processChunkedResponse)
.then(onChunkedResponseComplete)
.catch(onChunkedResponseError);
}

function processChunkedResponse(response) {
let text = "";
let reader = response.body.getReader();
let decoder = new TextDecoder();

return readChunk();

function readChunk() {
return reader.read().then(appendChunks);
}

function appendChunks(result) {
let chunk = decoder.decode(result.value || new Uint8Array(), {
stream: !result.done,
});
console.log("已接收到的数据:", chunk);
console.log("本次已成功接收", chunk.length, "bytes");
text += chunk;
console.log("目前为止共接收", text.length, "bytes\n");
if (result.done) {
return text;
} else {
return readChunk();
}
}
}

function onChunkedResponseComplete(result) {
let blob = new Blob([result], {
type: "text/plain;charset=utf-8",
});
saveAs(blob, "hello.txt");
}

function onChunkedResponseError(err) {
console.error(err);
}

当用户点击 下载 按钮时,就会调用以上代码中的 download 函数。在该函数内部,我们会使用 Fetch API 来执行下载操作。因为服务端的数据是以一系列分块的形式进行发送,所以在浏览器端我们是通过流的形式进行接收。即通过 response.body 获取可读的 ReadableStream,然后用 ReadableStream.getReader() 创建一个读取器,最后调用 reader.read 方法来读取已返回的分块数据。

因为 file.txt 文件的内容是普通文本,且 result.value 的值是 Uint8Array 类型的数据,所以在处理返回的分块数据时,我们使用了 TextDecoder 文本解码器。一个解码器只支持一种特定文本编码,例如 utf-8iso-8859-2koi8cp1261gbk 等等。

如果收到的分块非 终止块result.done 的值是 false,则会继续调用 readChunk 方法来读取分块数据。而当接收到 终止块 之后,表示分块数据已传输完成。此时,result.done 属性就会返回 true。从而会自动调用 onChunkedResponseComplete 函数,在该函数内部,我们以解码后的文本作为参数来创建 Blob 对象。之后,继续使用 FileSaver 库提供的 saveAs 方法实现文件下载。

这里我们用 Wireshark 网络包分析工具,抓了个数据包。具体如下图所示:

从图中我们可以清楚地看到在 HTTP chunked response 下面包含了 Data chunk(数据块) 和 End of chunked encoding(终止块)。接下来,我们来看一下服务端的代码。

8.2 服务端代码

const fs = require("fs");
const path = require("path");
const Koa = require("koa");
const cors = require("@koa/cors");
const Router = require("@koa/router");

const app = new Koa();
const router = new Router();
const PORT = 3000;

router.get("/file", async (ctx, next) => {
const { filename } = ctx.query;
const filePath = path.join(__dirname, filename);
ctx.set({
"Content-Type": "text/plain;charset=utf-8",
});
ctx.body = fs.createReadStream(filePath);
});

// 注册中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录
ctx.status = error.code === "ENOENT" ? 404 : 500;
ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差";
}
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());

app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});

在 /file 路由处理器中,我们先通过 ctx.query 获得 filename 文件名,接着拼接出该文件的绝对路径,然后通过 Node.js 平台提供的 fs.createReadStream 方法创建可读流。最后把已创建的可读流赋值给 ctx.body 属性,从而向客户端返回图片数据。

现在我们已经知道可以利用分块传输编码(Transfer-Encoding)实现数据的分块传输,那么有没有办法获取指定范围内的文件数据呢?对于这个问题,我们可以利用 HTTP 协议的范围请求。接下来,我们将介绍如何利用 HTTP 范围请求来下载指定范围的数据。

chunked 下载示例:chunked

github.com/semlinker/f…

九、范围下载

HTTP 协议范围请求允许服务器只发送 HTTP 消息的一部分到客户端。范围请求在传送大的媒体文件,或者与文件下载的断点续传功能搭配使用时非常有用。如果在响应中存在 Accept-Ranges 首部(并且它的值不为 “none”),那么表示该服务器支持范围请求。

在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误。服务器允许忽略 Range 首部,从而返回整个文件,状态码用 200 。

Range 语法:

Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
  • unit:范围请求所采用的单位,通常是字节(bytes)。
  • <range-start>:一个整数,表示在特定单位下,范围的起始值。
  • <range-end>:一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。

了解完 Range 语法之后,我们来看一下实际的使用示例:

# 单一范围
$ curl http://i.imgur.com/z4d4kWk.jpg -i -H "Range: bytes=0-1023"
# 多重范围
$ curl http://www.example.com -i -H "Range: bytes=0-50, 100-150"

9.1 前端代码

html

<h3>范围下载示例</h3>
<button onclick="download()">下载</button>

js

async function download() {
try {
let rangeContent = await getBinaryContent(
"http://localhost:3000/file.txt",
0, 100, "text"
);
const blob = new Blob([rangeContent], {
type: "text/plain;charset=utf-8",
});
saveAs(blob, "hello.txt");
} catch (error) {
console.error(error);
}
}

function getBinaryContent(url, start, end, responseType = "arraybuffer") {
return new Promise((resolve, reject) => {
try {
let xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.setRequestHeader("range", `bytes=${start}-${end}`);
xhr.responseType = responseType;
xhr.onload = function () {
resolve(xhr.response);
};
xhr.send();
} catch (err) {
reject(new Error(err));
}
});
}

当用户点击 下载 按钮时,就会调用 download 函数。在该函数内部会通过调用 getBinaryContent 函数来发起范围请求。对应的 HTTP 请求报文如下所示:

GET /file.txt HTTP/1.1
Host: localhost:3000
Connection: keep-alive
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36
Accept: */*
Accept-Encoding: identity
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,id;q=0.7
Range: bytes=0-100

而当服务器接收到该范围请求之后,会返回对应的 HTTP 响应报文:

HTTP/1.1 206 Partial Content
Vary: Origin
Access-Control-Allow-Origin: null
Accept-Ranges: bytes
Last-Modified: Fri, 09 Jul 2021 00:17:00 GMT
Cache-Control: max-age=0
Content-Type: text/plain; charset=utf-8
Date: Sat, 10 Jul 2021 02:19:39 GMT
Connection: keep-alive
Content-Range: bytes 0-100/2590
Content-Length: 101

从以上的 HTTP 响应报文中,我们见到了前面介绍的 206 状态码和 Accept-Ranges 首部。此外,通过 Content-Range 首部,我们就知道了文件的总大小。在成功获取到范围请求的响应体之后,我们就可以使用返回的内容作为参数,调用 Blob 构造函数创建对应的 Blob 对象,进而使用 FileSaver 库提供的 saveAs 方法来下载文件了。

9.2 服务端代码

const Koa = require("koa");
const cors = require("@koa/cors");
const serve = require("koa-static");
const range = require("koa-range");

const PORT = 3000;
const app = new Koa();

// 注册中间件
app.use(cors());
app.use(range);
app.use(serve("."));

app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});

服务端的代码相对比较简单,范围请求是通过 koa-range 中间件来实现的。由于篇幅有限,阿宝哥就不展开介绍了。感兴趣的小伙伴,可以自行阅读该中间件的源码。其实范围请求还可以应用在大文件下载的场景,如果文件服务器支持范围请求的话,客户端在下载大文件的时候,就可以考虑使用大文件分块下载的方案。

范围下载示例:range

github.com/semlinker/f…

十、大文件分块下载

相信有些小伙伴已经了解大文件上传的解决方案,在上传大文件时,为了提高上传的效率,我们一般会使用 Blob.slice 方法对大文件按照指定的大小进行切割,然后在开启多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并。

那么对大文件下载来说,我们能否采用类似的思想呢?其实在服务端支持 Range 请求首部的条件下,我们也是可以实现大文件分块下载的功能,具体处理方案如下图所示:

因为在 JavaScript 中如何实现大文件并发下载? 这篇文章中,阿宝哥已经详细介绍了大文件并发下载的方案,所以这里就不展开介绍了。我们只回顾一下大文件并发下载的完整流程:

其实在大文件分块下载的场景中,我们使用了 async-pool 这个库来实现并发控制。该库提供了 ES7 和 ES6 两种不同版本的实现,代码很简洁优雅。如果你想了解 async-pool 是如何实现并发控制的,可以阅读 JavaScript 中如何实现并发控制? 这篇文章。

大文件分块下载示例:big-file

github.com/semlinker/f…

十一、总结

本文阿宝哥详细介绍了文件下载的 9 种场景,希望阅读完本文后,你对 9 种场景背后使用的技术有一定的了解。其实在传输文件的过程中,为了提高传输效率,我们可以使用 gzipdeflate 或 br 等压缩算法对文件进行压缩。由于篇幅有限,阿宝哥就不展开介绍了,如果你感兴趣的话,可以阅读 HTTP 传输大文件的几种方案 这篇文章。



链接:https://juejin.cn/post/6989413354628448264

收起阅读 »

文件下载,搞懂这9种场景就够了(上)

既然掘友有要求,连标题也帮阿宝哥想好了,那我们就来整一篇文章,总结一下文件下载的场景。 一般在我们工作中,主要会涉及到 9 种文件下载的场景,每一种场景背后都使用不同的技术,其中也有很多细节需要我们额外注意。今天阿宝哥就来带大家总结一下这 9 种场景,让大家能...
继续阅读 »





既然掘友有要求,连标题也帮阿宝哥想好了,那我们就来整一篇文章,总结一下文件下载的场景。


一般在我们工作中,主要会涉及到 9 种文件下载的场景,每一种场景背后都使用不同的技术,其中也有很多细节需要我们额外注意。今天阿宝哥就来带大家总结一下这 9 种场景,让大家能够轻松地应对各种下载场景。阅读本文后,你将会了解以下的内容:



在浏览器端处理文件的时候,我们经常会用到 Blob 。比如图片本地预览、图片压缩、大文件分块上传及文件下载。在浏览器端文件下载的场景中,比如我们今天要讲到的 a 标签下载showSaveFilePicker API 下载Zip 下载 等场景中,都会使用到 Blob ,所以我们有必要在学习具体应用前,先掌握它的相关知识,这样可以帮助我们更好地了解示例代码。


一、基础知识


1.1 了解 Blob


Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。在 JavaScript 中 Blob 类型的对象表示一个不可变、原始数据的类文件对象。 它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 用于数据操作。


Blob 对象由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成:


在 JavaScript 中你可以通过 Blob 的构造函数来创建 Blob 对象,Blob 构造函数的语法如下:


const aBlob = new Blob(blobParts, options);

相关的参数说明如下:



  • blobParts:它是一个由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等对象构成的数组。DOMStrings 会被编码为 UTF-8。

  • options:一个可选的对象,包含以下两个属性:

    • type —— 默认值为 "",它代表了将会被放入到 blob 中的数组内容的 MIME 类型。

    • endings —— 默认值为 "transparent",用于指定包含行结束符 \n 的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变。




1.2 了解 Blob URL


Blob URL/Object URL 是一种伪协议,允许 Blob 和 File 对象用作图像、下载二进制数据链接等的 URL 源。在浏览器中,我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:<origin>/<uuid>,对应的示例如下:


blob:http://localhost:3000/53acc2b6-f47b-450f-a390-bf0665e04e59

浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。生成的 URL 仅在当前文档打开的状态下才有效。它允许引用 <img><a> 中的 Blob,但如果你访问的 Blob URL 不再存在,则会从浏览器中收到 404 错误。


上述的 Blob URL 看似很不错,但实际上它也有副作用。 虽然存储了 URL → Blob 的映射,但 Blob 本身仍驻留在内存中,浏览器无法释放它。映射在文档卸载时自动清除,因此 Blob 对象随后被释放。但是,如果应用程序寿命很长,那么 Blob 在短时间内将无法被浏览器释放。因此,如果你创建一个 Blob URL,即使不再需要该 Blob,它也会存在内存中。


针对这个问题,你可以调用 URL.revokeObjectURL(url) 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。


现在你已经了解了 Blob 和 Blob URL,如果你还意犹未尽,想深入理解 Blob 的话,可以阅读 你不知道的 Blob 这篇文章。下面我们开始介绍客户端文件下载的场景。


随着 Web 技术的不断发展,浏览器的功能也越来越强大。这些年出现了很多在线 Web 设计工具,比如在线 PS、在线海报设计器或在线自定义表单设计器等。这些 Web 设计器允许用户在完成设计之后,把生成的文件保存到本地,其中有一部分设计器就是利用浏览器提供的 Web API 来实现客户端文件下载。下面阿宝哥先来介绍客户端下载中,最常见的 a 标签下载 方案。


二、a 标签下载


html


<h3>a 标签下载示例</h3>
<div>
<img src="../images/body.png" />
<img src="../images/eyes.png" />
<img src="../images/mouth.png" />
</div>
<img id="mergedPic" src="http://via.placeholder.com/256" />
<button onclick="merge()">图片合成</button>
<button onclick="download()">图片下载</button>

在以上代码中,我们通过 img 标签引用了以下 3 张素材:



当用户点击 图片合成 按钮时,会将合成的图片显示在 img#mergedPic 容器中。在图片成功合成之后,用户可以通过点击 图片下载 按钮把已合成的图片下载到本地。对应的操作流程如下图所示:



由上图可知,整体的操作流程相对简单。接下来,我们来看一下 图片合成图片下载 的实现逻辑。


js


图片合成的功能,阿宝哥是直接使用 Github 上 merge-images 这个第三方库来实现。利用该库提供的 mergeImages(images, [options]) 方法,我们可以轻松地实现图片合成的功能。调用该方法后,会返回一个 Promise 对象,当异步操作完成后,合成的图片会以 Data URLs 的格式返回。


const mergePicEle = document.querySelector("#mergedPic");
const images = ["/body.png", "/eyes.png", "/mouth.png"].map(
(path) => "../images" + path
);
let imgDataUrl = null;

async function merge() {
imgDataUrl = await mergeImages(images);
mergePicEle.src = imgDataUrl;
}

而图片下载的功能是借助 dataUrlToBlobsaveFile 这两个函数来实现。它们分别用于实现 Data URLs => Blob 的转换和文件的保存,具体的代码如下所示:


function dataUrlToBlob(base64, mimeType) {
let bytes = window.atob(base64.split(",")[1]);
let ab = new ArrayBuffer(bytes.length);
let ia = new Uint8Array(ab);
for (let i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
return new Blob([ab], { type: mimeType });
}

// 保存文件
function saveFile(blob, filename) {
const a = document.createElement("a");
a.download = filename;
a.href = URL.createObjectURL(blob);
a.click();
URL.revokeObjectURL(a.href)
}

因为本文的主题是介绍文件下载,所以我们来重点分析 saveFile 函数。在该函数内部,我们使用了 HTMLAnchorElement.download 属性,该属性值表示下载文件的名称。如果该名称不是操作系统的有效文件名,浏览器将会对其进行调整。此外,该属性的作用是表明链接的资源将被下载,而不是显示在浏览器中。


需要注意的是,download 属性存在兼容性问题,比如 IE 11 及以下的版本不支持该属性,具体如下图所示:



(图片来源:caniuse.com/download)


当设置好 a 元素的 download 属性之后,我们会调用 URL.createObjectURL 方法来创建 Object URL,并把返回的 URL 赋值给 a 元素的 href 属性。接着通过调用 a 元素的 click 方法来触发文件的下载操作,最后还会调用一次 URL.revokeObjectURL 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。


关于 a 标签下载 的内容就介绍到这,下面我们来介绍如何使用新的 Web API —— showSaveFilePicker 实现文件下载。



a 标签下载示例:a-tag


github.com/semlinker/f…



三、showSaveFilePicker API 下载


showSaveFilePicker API 是 Window 接口中定义的方法,调用该方法后会显示允许用户选择保存路径的文件选择器。该方法的签名如下所示:



let FileSystemFileHandle = Window.showSaveFilePicker(options);


showSaveFilePicker 方法支持一个对象类型的可选参数,可包含以下属性:



  • excludeAcceptAllOption:布尔类型,默认值为 false。默认情况下,选择器应包含一个不应用任何文件类型过滤器的选项(由下面的 types 选项启用)。将此选项设置为 true 意味着 types 选项不可用。

  • types:数组类型,表示允许保存的文件类型列表。数组中的每一项是包含以下属性的配置对象:

    • description(可选):用于描述允许保存文件类型类别。

    • accept:是一个对象,该对象的 keyMIME 类型,值是文件扩展名列表。




调用 showSaveFilePicker 方法之后,会返回一个 FileSystemFileHandle 对象。有了该对象,你就可以调用该对象上的方法来操作文件。比如调用该对象上的 createWritable 方法之后,就会返回 FileSystemWritableFileStream 对象,就可以把数据写入到文件中。具体的使用方式如下所示:


async function saveFile(blob, filename) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [
{
description: "PNG file",
accept: {
"image/png": [".png"],
},
},
{
description: "Jpeg file",
accept: {
"image/jpeg": [".jpeg"],
},
},
],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return handle;
} catch (err) {
console.error(err.name, err.message);
}
}

function download() {
if (!imgDataUrl) {
alert("请先合成图片");
return;
}
const imgBlob = dataUrlToBlob(imgDataUrl, "image/png");
saveFile(imgBlob, "face.png");
}

当你使用以上更新后的 saveFile 函数,来保存已合成的图片时,会显示以下保存文件选择器:



由上图可知,相比 a 标签下载 的方式,showSaveFilePicker API 允许你选择文件的下载目录、选择文件的保存格式和更改存储的文件名称。看到这里是不是觉得 showSaveFilePicker API 功能挺强大的,不过可惜的是该 API 目前的兼容性还不是很好,具体如下图所示:



(图片来源:caniuse.com/?search=sho…


其实 showSaveFilePickerFile System Access API 中定义的方法,除了 showSaveFilePicker 之外,还有 showOpenFilePickershowDirectoryPicker 等方法。如果你想在实际项目中使用这些 API 的话,可以考虑使用 GoogleChromeLabs 开源的 browser-fs-access 这个库,该库可以让你在支持平台上更方便地使用 File System Access API,对于不支持的平台会自动降级使用 <input type="file"><a download> 的方式。


可能大家对 browser-fs-access 这个库会比较陌生,但是如果换成是 FileSaver.js 这个库的话,应该就比较熟悉了。接下来,我们来介绍如何利用 FileSaver.js 这个库实现客户端文件下载。



showSaveFilePicker API 下载示例:save-file-picker


github.com/semlinker/f…



四、FileSaver 下载


FileSaver.js 是在客户端保存文件的解决方案,非常适合在客户端上生成文件的 Web 应用程序。它是 HTML5 版本的 saveAs() FileSaver 实现,支持大多数主流的浏览器,其兼容性如下图所示:



(图片来源:github.com/eligrey/Fil…


在引入 FileSaver.js 这个库之后,我们就可以使用它提供的 saveAs 方法来保存文件。该方法对应的签名如下所示:



FileSaver saveAs(
Blob/File/Url,
optional DOMString filename,
optional Object { autoBom }
)


saveAs 方法支持 3 个参数,第 1 个参数表示它支持 Blob/File/Url 三种类型,第 2 个参数表示文件名(可选),而第 3 个参数表示配置对象(可选)。如果你需要 FlieSaver.js 自动提供 Unicode 文本编码提示(参考:字节顺序标记),则需要设置 { autoBom: true}


了解完 saveAs 方法之后,我们来举 3 个具体的使用示例:


1. 保存文本


let blob = new Blob(["大家好,我是阿宝哥!"], { type: "text/plain;charset=utf-8" });
saveAs(blob, "hello.txt");

2. 保存线上资源


saveAs("https://httpbin.org/image", "image.jpg");

如果下载的 URL 地址与当前站点是同域的,则将使用 a[download] 方式下载。否则,会先使用 同步的 HEAD 请求 来判断是否支持 CORS 机制,若支持的话,将进行数据下载并使用 Blob URL 实现文件下载。如果不支持 CORS 机制的话,将会尝试使用 a[download] 方式下载。


标准的 W3C File API Blob 接口并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 Blob.js 来解决兼容性问题。



(图片来源:caniuse.com/?search=blo…


3. 保存 canvas 画布内容


let canvas = document.getElementById("my-canvas");
canvas.toBlob(function(blob) {
saveAs(blob, "abao.png");
});

需要注意的是 canvas.toBlob() 方法并非在所有浏览器中都可用,对于这个问题,你可以考虑使用 canvas-toBlob.js 来解决兼容性问题。



(图片来源:caniuse.com/?search=toB…


介绍完 saveAs 方法的使用示例之后,我们来更新前面示例中的 download 方法:


function download() {
if (!imgDataUrl) {
alert("请先合成图片");
return;
}
const imgBlob = dataUrlToBlob(imgDataUrl, "image/png");
saveAs(imgBlob, "face.png");
}

很明显,使用 saveAs 方法之后,下载已合成的图片就很简单了。如果你对 FileSaver.js 的工作原理感兴趣的话,可以阅读 聊一聊 15.5K 的 FileSaver,是如何工作的? 这篇文章。前面介绍的场景都是直接下载单个文件,其实我们也可以在客户端同时下载多个文件,然后把已下载的文件压缩成 Zip 包并下载到本地。



FileSaver 下载示例:file-saver


github.com/semlinker/f…



五、Zip 下载


文件上传,搞懂这8种场景就够了 这篇文章中,阿宝哥介绍了如何利用 JSZip 这个库提供的 API,把待上传目录下的所有文件压缩成 ZIP 文件,然后再把生成的 ZIP 文件上传到服务器。同样,利用 JSZip 这个库,我们可以实现在客户端同时下载多个文件,然后把已下载的文件压缩成 Zip 包,并下载到本地的功能。对应的操作流程如下图所示:



在以上 Gif 图中,阿宝哥演示了把 3 张素材图,打包成 Zip 文件并下载到本地的过程。接下来,我们来介绍如何使用 JSZip 这个库实现以上的功能。


html


<h3>Zip 下载示例</h3>
<div>
<img src="../images/body.png" />
<img src="../images/eyes.png" />
<img src="../images/mouth.png" />
</div>
<button onclick="download()">打包下载</button>

js


const images = ["body.png", "eyes.png", "mouth.png"];
const imageUrls = images.map((name) => "../images/" + name);

async function download() {
let zip = new JSZip();
Promise.all(imageUrls.map(getFileContent)).then((contents) => {
contents.forEach((content, i) => {
zip.file(images[i], content);
});
zip.generateAsync({ type: "blob" }).then(function (blob) {
saveAs(blob, "material.zip");
});
});
}

// 从指定的url上下载文件内容
function getFileContent(fileUrl) {
return new JSZip.external.Promise(function (resolve, reject) {
// 调用jszip-utils库提供的getBinaryContent方法获取文件内容
JSZipUtils.getBinaryContent(fileUrl, function (err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}

在以上代码中,当用户点击 打包下载 按钮时,就会调用 download 函数。在该函数内部,会先调用 JSZip 构造函数创建 JSZip 对象,然后使用 Promise.all 函数来确保所有的文件都下载完成后,再调用 file(name, data [,options]) 方法,把已下载的文件添加到前面创建的 JSZip 对象中。最后通过 zip.generateAsync 函数来生成 Zip 文件并使用 FileSaver.js 提供的 saveAs 方法保存 Zip 文件。



Zip 下载示例:Zip


github.com/semlinker/f…





收起阅读 »

我给鸿星尔克写了一个720°看鞋展厅

最近因为鸿星尔克给河南捐了5000万物资,真的是看哭了很多的网友,普通一家公司捐款5000万可能不会有这样的共情,但是看了鸿星尔克的背景之后,发现真的是令人心酸。鸿星尔克2020年的营收是28亿,但是利润却是亏损2个亿,甚至连微博的官方账号都舍不得开会员,在这...
继续阅读 »


最近因为鸿星尔克给河南捐了5000万物资,真的是看哭了很多的网友,普通一家公司捐款5000万可能不会有这样的共情,但是看了鸿星尔克的背景之后,发现真的是令人心酸。鸿星尔克2020年的营收是28亿,但是利润却是亏损2个亿,甚至连微博的官方账号都舍不得开会员,在这种情况下,还豪气地捐赠5000万,真的是破防了。



网友还称鸿星尔克,特别像是老一辈人省吃俭用一分一毛攒起来的存款,小心翼翼存在铁盒里。一听说祖国需要,立马拿出铁盒子,哗~全导给你。让上最贵的鞋,拿出了双 249 的。


然后我去鸿星尔克的官网看了看他家的鞋子。



好家伙,等了55秒,终于把网站打开了。。。(看来真的是年久失修了,太令人心酸了。作为一个前端看到这一幕真的疯了...)


恰逢周末,我就去了离我最近的鸿星尔克看了看。买了一双 136 的鞋子(是真的便宜,最关键的还是舒服)。




买回家后心里想着,像毒APP上面那些阿迪、耐克的都有线上 360° 查看,就想着能不能给鸿星尔克也做一个呢,算作为一个技术人员为它出的一份绵薄之力。


行动


有了这个想法后,我就立马开始行动了。然后我大致总结了以下几个步骤:


1.建模


2.使用 Thee.js 创建场景


3.导入模型


4.加入 Three.js 控制器


由于之前学习了一些 Three.js 的相关知识,因此对于有了模型后的展示还是比较有底的,因此其中最麻烦的就是建模了,因为我们需要把一个3维的东西,放到电脑中。对于2维的物体,想要放到电脑上,我们都知道,非常简单,就是使用相机拍摄一下就好了,但是想要在电脑中查看3维的物体却不一样,它多了一个维度,增加的量确实成倍的增长,于是开始查阅各种资料来看如何能够建立一个物体的模型。



查了一堆资料,想要建立一个鞋子模型,总结起来共有两种模式。


1.摄影绘图法(photogrammetry):通过拍摄照片,通过纯算法转化成3d模型,在图形学中也称为单目重建 。


2.雷达扫描(Lidar scan):是通过激光雷达扫描,何同学的最新一期视频中也提到了这种方式扫描出点云。


放上一个我总结的大纲,大部分都是国外的网站/工具。



一开始搜索结果中,绝大多数人都在提 123D Catch,并且也看了很多视频,说它建立模型快速且逼真,但是再进一步的探索中,发现它貌似在2017年的时候业务就进行了合并进行了整合。整合后的 ReMake 需要付费,处于成本考虑我就没有继续了。(毕竟只是demo尝试)



后面发现一款叫做 Polycam 的软件,成品效果非常好。



但是当我选择使用的时候,发现它需要激光雷达扫描仪(LiDAR),必须要 iphone 12 pro 以上的机型才能使用。



最终我选择了 Reality Capture 来创建模型,他是可以通过多张图片来合成一个模型的方式,看了一些b站的视频,感觉它的呈像效果也不错,不过它只支持 windows,且运行内存需要8g,这个时候我搬出了我7年前的windows电脑... 发现没想到它还能服役,也是惊喜。


建模


下面就开始正式的内容,主角就是我这次买的鞋子(开头放的那双)



然后我们开始拍摄,首先我环绕着鞋子随意拍摄了一组照片,但是发现这个模型真的差强人意...



后面我也采用了白幕的形式,加了一层背景,后面发现还是不行,应用更多是识别到了后面的背景数字。



最后... 还是在楠溪的帮助下,将背景图P成了白色。



皇天不负有心人,最终的效果还不错,基本上的点云模型已经出来了。(这感觉还不错,有种电影里的黑科技的感觉)



下面是模型的样子,已经是我花了一天的时间训练出的最好的模型了(但是还是有一些轻微的粗糙)



为了尽可能的让模型看起来完美,中间一共花了一天的时间来测试模型,因为拍摄的角度以及非常影响模型的生成,我一共拍了大约1G的图片,大约500张图片(由于前期不懂如何调整模型,因此尝试了大量的方法。)




有了模型后,我们就可以将它展示在互联网上啦,这里采用了 Three.js(由于这里考虑到很多人不是这块领域相关的人员,因此会讲的比较基础,大佬们请见谅。)


构建应用


主要由三部分组成(构建场景、模型加载、添加控制器)


1.构建3d场景


首先我们先加载 Three.js


<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.129.0/build/three.module.js';
</script>

然后创建一个WebGL渲染器


const container = document.createElement( 'div' );
document.body.appendChild( container );

let renderer = new THREE.WebGLRenderer( { antialias: true } );
container.appendChild( renderer.domElement );

再将添加一个场景和照相机


let scene = new THREE.Scene();

相机语法PerspectiveCamera(fov, aspect, near, far)



// 设置一个透视摄像机
camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.25, 1000 );
// 设置相机的位置
camera.position.set( 0, 1.5, -30.0 );

将场景和相机添加到 WebGL渲染器中。


renderer.render( scene, camera );

2.模型加载


由于我们的导出的模型是 OBJ 格式的,体积非常大,我先后给它压成了 gltf、glb 的格式,Three.js 已经帮我们写好了GLTF 的loader,我们直接使用即可。


// 加载模型
const gltfloader = new GLTFLoader();
const draco = new DRACOLoader();
draco.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
gltfloader.setDRACOLoader(draco);
gltfloader.setPath('assets/obj4/');
gltfloader.load('er4-1.glb', function (gltf) {
gltf.scene.scale.set(0.2, 0.2, 0.2); //设置缩放
gltf.scene.rotation.set(-Math.PI / 2, 0, 0) // 设置角度
const Orbit = new THREE.Object3D();
Orbit.add(gltf.scene);
Orbit.rotation.set(0, Math.PI / 2, 0);

scene.add(Orbit);
render();
});

但是通过以上代码打开我们的页面会是一片漆黑,这个是因为我们的还没有添加光照。于是我们继续来添加一束光,来照亮我们的鞋子。


// 设置灯光
const directionalLight = new THREE.AmbientLight(0xffffff, 4);
scene.add(directionalLight);
directionalLight.position.set(2, 5, 5);


现在能够清晰地看到我们的鞋子了,仿佛黑暗中看到了光明,但是这时候无法通过鼠标或者手势控制的,需要用到我们 Three.js 的控制器来帮助我们控制我们的模型角度。


3.添加控制器


const controls = new OrbitControls( camera, renderer.domElement );
controls.addEventListener('change', render );
controls.minDistance = 2; // 限制缩放
controls.maxDistance = 10;
controls.target.set( 0, 0, 0 ); // 旋转中心点
controls.update()

这个时候我们就能从各个角度看我们的鞋子啦。



大功告成!


在线体验地址: resume.mdedit.online/erke/


开源地址(包含了工具、运行步骤以及实际demo):github.com/hua1995116/…


后续规划


由于时间有限(花了一整天周末的时间),还是没有得到一个非常完美的模型,后续也会继续探索这块的实现,再后续将探索是否能实现一条自动化的方式,从拍摄到模型的展示,以及其实我们有了模型后,离AR试鞋也不远了,如果你有兴趣或者有更好的想法建议,欢迎和我交流。


最后非常感谢楠溪,放下了原本计划的一些事情来帮助一起拍摄加后期处理,以及陪我处理了一整天的模型。(条件有限的拍摄真的太艰难了。)


还有祝鸿星尔克能够成为长久的企业,保持创新,做出更多更好的运动服装,让此刻的全民青睐的状态保持下去。


附录


得出的几个拍摄技巧,也是官方提供的。


1.不要限制图像数量,RealityCapture可以处理任意张图片。


2.使用高分辨率的图片。


3.场景表面中的每个点应该在至少两个高质量的图像中清晰可见。


4.拍照时以圆形方式围绕物体移动。


5.移动的角度不要超过30度以上。


6.从拍摄整个对象的照片,移动它然后关注细节,保证大小都差不多。


7.完整的环绕。(不要绕半圈就结束了)


链接:https://juejin.cn/post/6989439618877751303

收起阅读 »

「干货」面试官问我如何快速搜索10万个矩形?——我说RBush

前言 亲爱的coder们,我又来了,一个喜欢图形的程序员👩‍💻,前几篇文章一直都在教大家怎么画地图、画折线图、画烟花🎆,难道图形就是这样嘛,当然不是,一个很简单的问题, 如果我在canvas中画了10万个点,鼠标在画布上移动,靠近哪一个点,哪一个点高亮。有同学...
继续阅读 »

前言


亲爱的coder们,我又来了,一个喜欢图形的程序员👩‍💻,前几篇文章一直都在教大家怎么画地图、画折线图、画烟花🎆,难道图形就是这样嘛,当然不是,一个很简单的问题, 如果我在canvas中画了10万个点,鼠标在画布上移动,靠近哪一个点,哪一个点高亮。有同学就说遇事不决 用for循环遍历哇,我也知道可以用循环解决哇,循环解决几百个点可以,如果是几万甚至几百万个点你还循环,你想让用户等死?这时就引入今天的主角他来了就是Rbush


RBUSH


我们先看下定义,这个rbush到底能帮我们解决了什么问题?



RBush是一个high-performanceJavaScript库,用于点和矩形的二维空间索引。它基于优化的R-tree数据结构,支持大容量插入。空间索引是一种用于点和矩形的特殊数据结构,允许您非常高效地执行“此边界框中的所有项目”之类的查询(例如,比在所有项目上循环快数百倍)。它最常用于地图和数据可视化。



看定义他是基于优化的R-tree数据结构,那么R-tree又是什么呢?



R-trees是用于空间访问方法的树数据结构,即用于索引多维信息,例如地理坐标矩形多边形。R-tree 在现实世界中的一个常见用途可能是存储空间对象,例如餐厅位置或构成典型地图的多边形:街道、建筑物、湖泊轮廓、海岸线等,然后快速找到查询的答案例如“查找我当前位置 2 公里范围内的所有博物馆”、“检索我所在位置 2 公里范围内的所有路段”(以在导航系统中显示它们)或“查找最近的加油站”(尽管不将道路进入帐户)。



R-tree的关键思想是将附近的对象分组,并在树的下一个更高级别中用它们的最小边界矩形表示它们;R-tree 中的“R”代表矩形。由于所有对象都位于此边界矩形内,因此不与边界矩形相交的查询也不能与任何包含的对象相交。在叶级,每个矩形描述一个对象;在更高级别,聚合包括越来越多的对象。这也可以看作是对数据集的越来越粗略的近似。说着有点抽象,还是看一张图:


R-tree


我来详细解释下这张图:



  1. 首先我们假设所有数据都是二维空间下的点,我们从图中这个R8区域说起,也就是那个shape of data object。别把那一块不规则图形看成一个数据,我们把它看作是多个数据围成的一个区域。为了实现R树结构,我们用一个最小边界矩形恰好框住这个不规则区域,这样,我们就构造出了一个区域:R8。R8的特点很明显,就是正正好好框住所有在此区域中的数据。其他实线包围住的区域,如R9,R10,R12等都是同样的道理。这样一来,我们一共得到了12个最最基本的最小矩形。这些矩形都将被存储在子结点中。

  2. 下一步操作就是进行高一层次的处理。我们发现R8,R9,R10三个矩形距离最为靠近,因此就可以用一个更大的矩形R3恰好框住这3个矩形。

  3. 同样道理,R15,R16被R6恰好框住,R11,R12被R4恰好框住,等等。所有最基本的最小边界矩形被框入更大的矩形中之后,再次迭代,用更大的框去框住这些矩形。


算法


插入


为了插入一个对象,树从根节点递归遍历。在每一步,检查当前目录节点中的所有矩形,并使用启发式方法选择候选者,例如选择需要最少放大的矩形。搜索然后下降到这个页面,直到到达叶节点。如果叶节点已满,则必须在插入之前对其进行拆分。同样,由于穷举搜索成本太高,因此采用启发式方法将节点一分为二。将新创建的节点添加到上一层,这一层可以再次溢出,并且这些溢出可以向上传播到根节点;当这个节点也溢出时,会创建一个新的根节点并且树的高度增加。


搜索


范围搜索中,输入是一个搜索矩形(查询框)。搜索从树的根节点开始。每个内部节点包含一组矩形和指向相应子节点的指针,每个叶节点包含空间对象的矩形(指向某个空间对象的指针可以在那里)。对于节点中的每个矩形,必须确定它是否与搜索矩形重叠。如果是,则还必须搜索相应的子节点。以递归方式进行搜索,直到遍历所有重叠节点。当到达叶节点时,将针对搜索矩形测试包含的边界框(矩形),如果它们位于搜索矩形内,则将它们的对象(如果有)放入结果集中。


读着就复杂,但是社区里肯定有大佬替我们封装好了,就不用自己再去手写了,写了写估计不一定对哈哈哈。


RBUSH 用法


用法


// as a ES module
import RBush from 'rbush';

// as a CommonJS module
const RBush = require('rbush');

创建一个树🌲


const tree = new RBush(16);

后面的16 是一个可选项,RBush 的一个可选参数定义了树节点中的最大条目数。 9(默认使用)是大多数应用程序的合理选择。 更高的值意味着更快的插入和更慢的搜索,反之亦然


插入数据📚


const item = {
   minX: 20,
   minY: 40,
   maxX: 30,
   maxY: 50,
   foo: 'bar'
};
tree.insert(item);

删除数据📚


tree.remove(item);

默认情况下,RBush按引用移除对象。但是,您可以传递一个自定义的equals函数,以便按删除值进行比较,当您只有需要删除的对象的副本时(例如,从服务器加载),这很有用:


tree.remove(itemCopy, (a, b) => {
   return a.id === b.id;
});

删除所有数据


tree.clear();

搜索🔍


const result = tree.search({
   minX: 40,
   minY: 20,
   maxX: 80,
   maxY: 70
});

api 介绍完毕下面👇开始进入实战环节一个简单的小案例——canvas中画布搜索🔍的。


用图片填充画布


填充画布的的过程中,这里和大家介绍一个canvas点的api ——createPattern



CanvasRenderingContext2D .createPattern()是 Canvas 2D API 使用指定的图像 (CanvasImageSource)创建模式的方法。 它通过repetition参数在指定的方向上重复元图像。此方法返回一个CanvasPattern对象。



第一个参数是填充画布的数据源可以是下面这:



第二个参数指定如何重复图像。允许的值有:



如果为空字符串 ('') 或 null (但不是 undefined),repetition将被当作"repeat"。


代码如下:


 class search { 
constructor() {
this.canvas = document.getElementById('map')
this.ctx = this.canvas.getContext('2d')
this.tree = new RBush()
this.fillCanvas()
}

fillCanvas() {
const img = new Image()
img.src ='https://ztifly.oss-cn-hangzhou.aliyuncs.com/%E6%B2%B9%E7%94%BB.jpeg'
img.onload = () => {
const pattern = this.ctx.createPattern(img, '')
this.ctx.fillStyle = pattern
this.ctx.fillRect(0, 0, 960, 600)
}
}
}

这边有个小提醒的就是图片加载成功的回调里面去给画布创建模式,然后就是this 指向问题, 最后就是填充画布。


如图:


image-20210722220842530


数据的生成


数据生成主要在画布的宽度 和长度的范围内随机生成10万个矩形。插入到rbush数据的格式就是有minX、maxX、minY、maxY。这个实现的思路也是非常的简单哇, minX用画布的长度Math.random minY 就是画布的高度Math.random. 然后最大再此基础上随机*20 就OK了,一个矩形就形成了。这个实现的原理就是左上和右下两个点可以形成一个矩形。代码如下:


randomRect() {
 const rect = {}
 rect.minX = parseInt(Math.random() * 960)
 rect.maxX = rect.minX + parseInt(Math.random() * 20)
 rect.minY = parseInt(Math.random() * 600)
 rect.maxY = rect.minY + parseInt(Math.random() * 20)
 rect.name = 'rect' + this.id
 this.id += 1
 return rect
}

然后循环加入10万条数据:


loadItems(n = 100000) {
let items = []
for (let i = 0; i < n; i++) {
  items.push(this.randomRect())
}
this.tree.load(items)
}

画布填充


这里我创建一个和当前画布一抹一样的canvas,但是里面画了n个矩形,将这个画布 当做图片填充到原先的画布中。


memCanva() {
 this.memCanv = document.createElement('canvas')
 this.memCanv.height = 600
 this.memCanv.width = 960
 this.memCtx = this.memCanv.getContext('2d')
 this.memCtx.strokeStyle = 'rgba(255,255,255,0.7)'
}

loadItems(n = 10000) {
 let items = []
 for (let i = 0; i < n; i++) {
   const item = this.randomRect()
   items.push(item)
   this.memCtx.rect(
     item.minX,
     item.minY,
     item.maxX - item.minX,
     item.maxY - item.minY
  )
}
 this.memCtx.stroke()
 this.tree.load(items)
}

然后在加载数据的时候,在当前画布画了10000个矩形。这时候新建的画布有东西了,然后我们用一个drawImage api ,


这个api做了这样的一个事,就是将画布用特定资源填充,然后你可以改变位置,后面有参数可以修改,这里我就不多介绍了, 传送门


this.ctx.drawImage(this.memCanv, 0, 0)

我们看下效果:
画布填充效果


添加交互


添加交互, 就是对画布添加mouseMove 事件, 然后呢我们以鼠标的位置,形成一个搜索的数据,然后我在统计花费的时间,然后你就会发现,这个Rbush 是真的快。代码如下:


 this.canvas.addEventListener('mousemove', this.handler.bind(this))
// mouseMove 事件
handler(e) {
   this.clearRect()
   const x = e.offsetX
   const y = e.offsetY
   this.bbox.minX = x - 20
   this.bbox.maxX = x + 20
   this.bbox.minY = y - 20
   this.bbox.maxY = y + 20
   const start = performance.now()
   const res = this.tree.search(this.bbox)
   this.ctx.fillStyle = this.pattern
   this.ctx.strokeStyle = 'rgba(255,255,255,0.7)'
   res.forEach((item) => {
     this.drawRect(item)
  })
   this.ctx.fill()
   this.res.innerHTML =
     'Search Time (ms): ' + (performance.now() - start).toFixed(3)
}

这里给大家讲解一下,现在我们画布是黑白的, 然后以鼠标搜索到数据后,然后我们画出对应的矩形,这时候呢,可以将矩形的填充模式改成 pattern 模式,这样便于我们看的更加明显。fillStyle可以填充3种类型:


ctx.fillStyle = color;
ctx.fillStyle = gradient;
ctx.fillStyle = pattern;

分别代表的是:


填充的模式


OK讲解完毕, 直接gif 看在1万个矩形的搜索中Rbush的表现怎么样。
rbush 演示
这是1万个矩形我换成10万个矩形我们在看看效果:


10万个点


我们发现增加到10万个矩形,速度还是非常快的,也就是1点几毫秒,增加到100万个矩形,canvas 已经有点画不出来了,整个页面已经卡顿了,这边涉及到canvas的性能问题,当图形的数量过多,或者数量过大的时候,fps会大幅度下降的。可以采用批量绘制的方法,还有一种优化手段是分层渲染


我引用一下官方的Rbush的性能图,供大家参考。


image.png


总结


最后总结下:rbush 是一种空间索引搜索🔍算法,当你涉及到空间几何搜索的时候,尤其在地图场景下,因为Rbush 实现的原理是比较搜索物体的boundingBox 和已知的boundingBox 求交集, 如果不相交,那么在树的遍历过程中就已经过滤掉了。


链接:https://juejin.cn/post/6989027459601547294

收起阅读 »

我们是如何封装项目里的共用弹框的

前言 随着产品的迭代,项目里的弹框越来越多,业务模块共用的弹框也比较多。在刚开始的阶段,有可能不是共用的业务弹框,我们只放到了当前的业务模块里。随着迭代升级,有些模块会成为通用弹框。简而言之,一个弹框会在多个页面中使用。举例说下我们的场景。 项目当中有这样一个...
继续阅读 »

前言


随着产品的迭代,项目里的弹框越来越多,业务模块共用的弹框也比较多。在刚开始的阶段,有可能不是共用的业务弹框,我们只放到了当前的业务模块里。随着迭代升级,有些模块会成为通用弹框。简而言之,一个弹框会在多个页面中使用。举例说下我们的场景。


项目当中有这样一个预览的弹框,已经存放在我们的业务组件当中。内容如下


import React from 'react';
import {Modal} from 'antd';

const Preview = (props) => {
const {visible, ...otherProps} = props;
return(
<Modal
visible={visible}
{...otherProps}
... // 其它Props
>
<div>预览组件的内容</div>
</Modal>
)
}

这样的一个组件我们在多个业务模块当中使用,下面我们通过不同的方式来处理这种情况。


各模块引入组件


组件是共用的,我们可以在各业务模块去使用。


在模块A中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

const A = () => {
const [previewState, setPreviewState] = useState({
visible: false,
... // 其它props,包括弹框的props和预览需要的参数等
});

// 显示弹框
const showPreview = () => {
setPreviewState({
...previewState,
visible: true,
})
}

// 关闭弹框
const hidePreview = () => {
setPreviewState({
...previewState,
visible: false,
})
}

return (<div>
<Button onClick={showPreview}>预览</Button>
<Preview {...previewState} onCancel={hidePreview} />
</div>)
}

export default A;

在模块B中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

const B = () => {
const [previewState, setPreviewState] = useState({
visible: false,
... // 其它props,包括弹框的props和预览需要的参数等
});

// 显示弹框
const showPreview = () => {
setPreviewState({
...previewState,
visible: true,
})
}

// 关闭弹框
const hidePreview = () => {
setPreviewState({
...previewState,
visible: false,
})
}

return (<div>
B模块的业务逻辑
<Button onClick={showPreview}>预览</Button>
<Preview {...previewState} onCancel={hidePreview} />
</div>)
}

export default B;

我们发现打开弹框和关闭弹框等这些代码基本都是一样的。如果我们的系统中有三四十个地方需要引入预览组件,那维护起来简直会要了老命,每次有调整,需要改动的地方太多了。


放到Redux中,全局管理。


通过上面我们可以看到显示很关闭的业务逻辑是重复的,我们把它放到redux中统一去管理。先改造下Preview组件


import React from 'react';
import {Modal} from 'antd';

@connect(({ preview }) => ({
...preview,
}))
const Preview = (props) => {
const {visible} = props;

const handleCancel = () => {
porps.dispatch({
type: 'preview/close'
})
}

return(
<Modal
visible={visible}
onCancel={handleCancel}
... // 其它Props
>
<div>预览组件的内容</div>
</Modal>
)
}

在redux中添加state管理我们的状态和处理一些参数


const initState = {
visible: false,
};

export default {
namespace: 'preview',
state: initState,
reducers: {
open(state, { payload }) {
return {
...state,
visible: true,
};
},
close(state) {
return {
...state,
visible: false,
};
},
},

};


全局引入


我们想要在模块中通过dispatch去打开我们弹框,需要在加载这些模块之前就导入我们组件。我们在Layout中导入组件


import Preview from './components/preview';
const B = () => {

return (<div>
<Header>顶部导航</Header>
<React.Fragment>
// 存放我们全局弹框的地方
<Preview />
</React.Fragment>
</div>)
}

export default B;

在模块A中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

@connect()
const A = (porps) => {
// 显示弹框
const showPreview = () => {
porps.dispatch({
type: 'preview/show'
payload: { ... 预览需要的参数}
})
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default A;

在模块B中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

@connect()
const B = () => {
// 显示弹框
const showPreview = () => {
this.porps.dispatch({
type: 'preview/show'
payload: { ... 预览需要的参数}
})
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default B;

放到redux中去管理状态,先把弹框组件注入到我们全局当中,我们在业务调用的时候只需通过dispatch就可以操作我们的弹框。


基于插件注入到业务当中


把状态放到redux当中,我们每次都要实现redux那一套流程和在layout组件中注入我们的弹框。我们能不能不关心这些事情,直接在业务当中使用呢。


创建一个弹框的工具类


class ModalViewUtils {

// 构造函数接收一个组件
constructor(Component) {
this.div = document.createElement('div');
this.modalRef = React.createRef();
this.Component = Component;
}

onCancel = () => {
this.close();
}

show = ({
title,
...otherProps
}: any) => {
const CurrComponent = this.Component;
document.body.appendChild(this.div);
ReactDOM.render(<GlobalRender>
<Modal
onCancel={this.onCancel}
visible
footer={null}
fullScreen
title={title || '预览'}
destroyOnClose
getContainer={false}
>
<CurrComponent {...otherProps} />
</ZetModal>
</GlobalRender>, this.div)

}

close = () => {
const unmountResult = ReactDOM.unmountComponentAtNode(this.div);
if (unmountResult && this.div.parentNode) {
this.div.parentNode.removeChild(this.div);
}
}

}

export default ModalViewUtils;

更改Preview组件


import React, { FC, useState } from 'react';
import * as ReactDOM from 'react-dom';
import ModalViewUtils from '../../utils/modalView';

export interface IModalViewProps extends IViewProps {
title?: string;
onCancel?: () => void;
}

// view 组件的具体逻辑
const ModalView: FC<IModalViewProps> = props => {
const { title, onCancel, ...otherProps } = props;
return <View isModal {...otherProps} />
}

// 实例化工具类,传入对用的组件
export default new ModalViewUtils(ModalView);


在模块A中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

const A = (porps) => {
// 显示弹框
const showPreview = (params) => {
Preview.show()
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default A;

在模块B中使用


import React, {useState} from 'react';
import Preview from './components/preview';
import {Button} from 'antd';

const B = () => {
// 显示弹框
const showPreview = () => {
Preview.show(params)
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default B;


基于这种方式,我们只用关心弹框内容的实现,调用的时候直接引入组件,调用show方法, 不会依赖redux,也不用再调用的地方实例组件,并控制显示隐藏等。


基于Umi插件,不需引入模块组件


我们可以借助umi的插件,把全局弹框统一注入到插件当中, 直接使用。


import React, {useState} from 'react';
import {ModalView} from 'umi';
import {Button} from 'antd';

const A = () => {
// 显示弹框
const showPreview = () => {
ModalView.Preview.show(params)
}
return (<div>
<Button onClick={showPreview}>预览</Button>
</div>)
}

export default A

结束语


对全局弹框做的统一处理,大家有问题,评论一起交流。


链接:https://juejin.cn/post/6989158134530965512

收起阅读 »

一个优秀前端的工具素养

👆 这句话,想然大家道理都懂 ~ 但最近在暑期实习的日子里,我特意留心观察了一下身边的实习生同学使用工具的习惯。我发现自己在大学认为高效率的工作模式,他们无论在意识层面还是在使用层面上对工具的掌握都有些蹩脚。特别是有部分同学 Mac 也没有怎么接触过,算是效率...
继续阅读 »

👆 这句话,想然大家道理都懂 ~


但最近在暑期实习的日子里,我特意留心观察了一下身边的实习生同学使用工具的习惯。我发现自己在大学认为高效率的工作模式,他们无论在意识层面还是在使用层面上对工具的掌握都有些蹩脚。特别是有部分同学 Mac 也没有怎么接触过,算是效率领域的门外汉了。所以本着做个负责的好师兄的态度,我将自己对工具使用的经验,分享给大家。也算是抛砖引玉,和大家一起聊聊有哪些 NB 又和好玩的工具。



需要注意的是:我这里主要以 Mac Apple 生态作为基调,但我相信工具和效率提升的思想是不变的,Win 下也有具体的工具可以替代,所以 Win 的同学也可以认真找一找,评论回复一下 Win 下的替代方案吧 🙏🏻



当然,👇 的工具,我没有办法在这种汇总类的文章里面讲透彻,所以都「点到为止」,给了相关扩展阅读的文章,所以感兴趣的话大家再外链出去研究一番,或者自行 Google 社区的优质资源 ~


所以准备好了么?Here we go ~


image.png


🛠 前端工作中的那些工具


在开始聊前端的主流工具之前,先强调一下作为的 Coder,熟练,及其熟练,飞一般熟练快捷键的重要性!


成为快捷键爱好者


使用工具软件的时候,我都是下意识地要求自己记住至少 Top 20 操作的「快捷键」。虽然不至于要求一定要成为 vim 编辑者这种级别的「纯金键盘侠」,但至少对 VSCode 主流快捷键要形成「肌肉记忆」。这就问大家一个问题,如果能够回答上,说明大家对 VSCode 快捷键掌握还是不错的 ~ 来:


问:VSCode 中 RenameSymbol 的快捷键是什么?(P.S. 若 Rename Symbol 都不知道是什么作用的话,去打板子吧 😄)


image.png


如果回答不上,那请加油了,相信我,快捷键每次操作都可以节省你至少 1s 的时间,想一想,有多划算吧 ~
当然在这里给大家推荐一个查询 Mac 下面应用对「快捷键」注册的工具 —— CheatSheet,长按 Command 键可以激活当前使用 App 的快捷键菜单。like this 👇


image.png


捷键没有速成之法,还是在不断重复练习,所以 KEEP ON DOING THAT


成为 VSCode Professional


工具,也有时髦之说,自从 Typescript 开始泛滥之后,VSCode 确乎成为了主流的前端开发工具。但我发很多同学对 VSCode 的使用上还是处于一种入门水准,没有真正发挥好这个工具的强大之处 ~ 所以也在和大家一起聊一聊。我不打算写一篇 Bible 级别的 VSCode 指南,只是通过几个小 Case 告诉大家 VSCode 有很多有趣的点可以使用以极大程度上提升效率,尤其是 VSCode Extensions(插件)。



  1. 你知道 VSCode 是可以云同步配置的功能,且可以一键同步其它设备么?

  2. 你知道 VSCode 有一个可以自动给 Typescript 的 import 排序并自动清除无效依赖的插件么?

  3. 你知道 VSCode 可以使用快捷键自动折叠代码层数么?

  4. 你知道如何快速返回到上一个编辑或者浏览过的文件吗?


如果都知道,那还不错 👍,如果听都没听说过,那我给大家几个建议:



  • 把 VSCode 的快捷键列表看一遍,摘出来自己觉得可以将来提升自己编码效率的,反复执行,直到形成肌肉记忆。

  • 把 VSCode 安装量和受欢迎度 Top200 的插件,浏览一遍,看看简介,安装自己感兴趣的插件。 👈 来一场探索宝藏的游戏吧,少读一些推荐文章,多动手自己捣鼓,找到好工具!




  • 最后把 VSCode 上一个绝美的皮肤和字体,按照我的审美,这就是我要的「滑板鞋」 ~ btw,主题是 OneDarkPro 字体是:FiraCode





扩展阅读:



用好 Terminal


作为一个工程师,不要求你成为 Shell 大师,但 Terminal 里面的常用命令以及日常美化优化还是必须要做的。这里给大家推荐 iTerm + OhMyZsh 的模式,打造一个稳定好用的 Terminal。



  • 下载 iTerm 安装(你用 VSCode 的也行,但我还是推荐独立终端 App,因为 VSCode 这货有时候会假死,然后把 iTerm 一下整没了,所以还是术业有专攻才行 🙈),有了这货,分屏幕上 👇 就是常规操作了。




  • 下载 OhMyZsh 安装,更新最新的 Git 目录,把主流插件都 down 下来,装好后秒变彩色,再安装对应的主题,不要太开心。




  • 按照个人兴趣「调教」OhMyZsh,强烈建议在 ~/.zshrc 启动这些插件:谁用谁知道 ~ 😄 随便说一个功能都好用到不行,这里就不啰嗦了,有其它好用插件的同学,欢迎盖楼分享一下。




plugins=(git osx wd autojump zsh-autosuggestions copyfile history last-working-dir)


比如:Git 这个插件就可以将复杂的 git 命令 git checkout -b 'xxx' 简化为:gcb 'xxx'


比如:OSX 插件可以帮我们快速打开 Finder 等等操作。


...




扩展阅读:




  • Shell 编程入门:手撸脚本,提升效率 ✍🏻




  • OhMyZsh 插件集:看那些花里胡哨的 shell 插件们,来,拉出来都晒一晒 🌞




  • Vim 快捷键 CheatSheet:在手撸服务器时代,Vim 是神器,现在看来依旧值得传火 🧎‍♂️ 大神收下我的膝盖




用好 Chrome DebugTool


作为一个前端我就不赘述这个的重要性了。强烈建议大家把官方文档撸一遍,你会有很多新知的。


developer.chrome.com/docs/devtoo…


👆 这个可以写一本书,但是我还是建议大家用好这个工具,这是我们前端最重要的调试器了,我经常在面试同学的时候会问关于他们如何使用调试器解决问题的。其实看大家调试代码的过程就知道这个同学的编程水准,真的,大家可以有意识的看看自己是怎么去调试和排查问题的,效率高么?有没有提升空间。



  • 比如:如何排查一个项目的渲染卡顿点?

  • 比如:如何排查内存泄露?

  • 比如:如何全局搜索查找重复的代码?


用好 ChromeExtensions


浏览器插件,我就不多说了。我在此罗列一下我日常使用的 Chrome 插件,也欢迎各路神仙补充自己的浏览器插件和那些骚操作。重点说一下 For 开发者的:





  • JSONFormatter:对于日常直接请求的 JSON 数据格式化




  • XSwitch:我前 TL 手撸的浏览器网络请求代理工具,帮他打个广告 😛




  • ReactDeveloerTools 👈 这个就不多解释了,强烈建议大家打开 HighlightRerender 功能,看看自己的代码写得多烂,多多批判一下自己 🙈




对于 Chrome Extension 这种「神文」比较多,像油猴、AdBlock、视频下载啥的之类的工具我就不在这里提了,懂的人都懂,不懂的人自己 Google。我这里再推荐几篇文章,大家按需阅读吧:



  • Chrome 前端插件推荐:B 乎上这个 问题和 回答 比较中肯

  • Chrome 通用插件推荐:B 乎继续 推荐,看看高赞回答下的「集体智慧」吧 😁


🔍 搜索!搜索!!搜索!!!


呼,终于聊完了开发用的工具,那接下来我们来聊一下搜索。按照我的理解,我一直把数字化时代个人信息管理的效率分成三个基础段位:



  • 入门级:很少整理自己的磁盘和桌面,典型特征就是桌面什么奇葩的命名文件都堆在一起

  • 新手级:开始有意识整理了,文件分级分层,重视文件命名,建立标签等机制

  • 熟练级:开始有意识建立数据库索引,在 OS 层面做文件索引,有数据意识

  • 大师级:开始关注数据,将个人数据,集体数据融入日常,甚至开始使用非结构化的数据来辅助自己做事情


扪心自问,你在哪一个 Level 呢?


Spotlight


第一第二级,我就不了了,这里我重点和大家分享一下达到第三级的索引和搜索工具。要知道在 Mac 下 Spotlight 一直都是一个全局搜索工具,用好 Spotlight,就可以无缝解锁系统级别的搜索,主要的 Apps、文件、日历 ... 都可以搜索。



Alfred



但系统自带的,往往都不是最强的是吧?所以在 Spotlight 系统级的 metadata (Mac 会自建文件索引库并开放 API 给上层应用调用)的基础上,诞生了一个很强的工具 Alfred。我一直是 Alfred 的资深粉丝 + 用户,每天使用 Alfred 的功能(搜索)高达 70 次。👇 图为证:



Alfred 是一个「真正意义上的效率工具」,其主要的功能:



  • 文档检索

  • 快捷 URL 拼接

  • 剪切板历史快速访问 & 搜索

  • BookMark 搜索

  • 自定义工作流(下一个章节重点聊一聊这个)

  • ...(功能无敌)


强烈建议不知道 Alfred 是啥的同学,读一下 👇 这篇文章,这篇文章是我在入职阿里第一年内网写的一篇介绍 Alfred 的文章,如果有收获,还请给我点个赞了 👍


此处为语雀内容卡片,点击链接查看:http://www.yuque.com/surfacew/fe…


🚌 自动化的魅力


「自动化」一定是一种程序工作者应该深深植入自己「脑海里」的思考模式。但凡遇到重复的流程,我们是不是都应该尝试着问自己,这么费时间的流程,频次有多少,是否值得我们使用工具去自动化?


如今,靠做自动化上市的公司也有了,所以这里重点想和大家一起聊一聊个人如何把身边的自动化做到极致。这里重点讲三个工具:Alfred Workflow、Apple 捷径、IFFTT。


AlfredWorkflow


主打 Mac 上的自动化流程。通过 👇 这种可视化编程的思路,创建一种动作流。比如我想实现通过 Cmd + Alt + B 搜索 Chrome 书签 🔖。社区的小伙伴们就已经帮我们实现了一套工作流。我们可以直接在 Alfred 的社区 Packtal 等论坛去下载已经实现的 Workflow 去实现这些日常生活中的小自动化流程。



再比如上面的:




  • ChromeHistory:搜索 Chrome 历史记录(在 Alfred 搜索中)




  • GithubRepos:浏览搜索自己的 GithubRepo




  • Colors:快速转换前端颜色(前端同学一定知道为什么这个常用)🙈






  • ... 等等等等


我们也可以定义自己的工作流来自动化一些流程,我用自身的一个 Case 来说,我会定义很多快捷键来绑定我自己的日常操作。比如:




  • Cmd + Alt + D:打开钉钉




  • Alfred 输入 weather:查询天气




  • Alfred 输入 calendar:打开百度日历(不为别的,看放假日历 😄)




  • codereview:进入集团 CR 的工作台




  • ...





浑然一体,非常好玩,可以大量定制自己的工作流程。我之前写过一篇文章有关联到 Workflow 的部分,感兴趣的可以 一读


AppleShortcuts


主打手机上的自动化流程。(iPhone)


它提供了近乎 0 代码的流程编排,让我们可以访问 App 以及一些操作系统的 API,从而实现类似 👆 Alfred 的功能编排,是不是也很强。比如我们想要实现一个从剪切板里面读取内容并打开网页的功能,只需要下面增加两个简单的编程动作(真 0 代码)就可以实现自定义流程的实现。



Apple 捷径提供的 API 示意:




可以看到的是,Apple 这些大厂一直在思考真正意义上的让编码平易近人,让普通的小白用户也可以低成本地定义自己的工作流程。Shortcuts 的玩法有很多,在这里就不细细展开了,给大家足够多探索的可能性。


IFFTT


🔗:ifttt.com/home


三方中立的自动化流程提供商。这个工具跨平台多端支持,我用的相对偏少,但可以解决我部分跨平台的流程问题,这块大家自行探索吧 ~


聪明的人,一定会用「自动化」的思维解决问题,所以用好自动化的工具的重要性我相信大家应该明白了。


💻 突破次元壁の工具


最后,再和大家聊一聊非软件的「工具」吧。我还是觉得大家作为 Coder,还是要在自己的装备上多花点盘缠,就像 Kevin 老师用了戴森吹风机就比普通发型师厉害一样。



  • 自己的 主力机,一定是要性能杠杠的,经济允许的情况下,前端我还是力挺 Mac(高配) 和 Apple 生态 ~

  • 给自己 一块 4K 屏(最好放公司),看着心情都会变好,如果财力雄厚,搞一块 Apple 的 PRO DISPLAY XDR,就给跪了。




  • 使用 iPad & ApplePencil 尝试着数字笔记的艺术,涂涂画画,发现灵感,整理思维。





  • 自动升降桌 & 人体工程学椅:对身体,脊椎好一点 🙂 就坐屁股变大,变胖,是不争的事实 😿




  • HHKB 键盘 ⌨️,最近用了一段时间,适应布局之后,觉得打字都变快了 ... 可能是金钱的力量让代码翘起来都更顺心了呢 🎶(开个玩笑)




  • ...




🎓 结语


当然,👆 的工具只是大千世界中,集体智慧凝练的工具的冰山一角。


这些工具提升效率创造的增益往往初步看很小,但是大家一定要知道,这种增益会随着时间积累而放大,做一个简单的计算,一天你因为工具里面的 100 次效率优化,每一次即便是优化 5s,一年下来,节省的时间(Alfred 可以直接计算时间):



是不是令人震惊地高达 50 个小时,活生生的 2 天 啊!!!受限于篇幅,如果大家觉得对这篇文章对自己有帮助的话,欢迎点赞收藏转载(怎么变成了 B 站三连了)哈哈,如果后续有时间的话,我再和大家一起分享一下我是如何做信息管理和知识管理的,希望能够给大家带来一些真正帮助。


链接:https://juejin.cn/post/6989033473457520653

收起阅读 »

你能不能在网页里实现裸眼3D

前言 最近产品经理在掘金社区的出镜率很高,看来大家都很喜闻乐见工程师与产品经理的相爱相杀。 这次他让我调研一下在网页里实现裸眼3D 这是故意为难我把? 搞什么调研影响我摸鱼 现在的我想拿枪打他 拿弓箭射他 点火烧他 诶,如果我在3D场景中刻意加上一些框框...
继续阅读 »

前言


最近产品经理在掘金社区的出镜率很高,看来大家都很喜闻乐见工程师与产品经理的相爱相杀。


这次他让我调研一下在网页里实现裸眼3D


这是故意为难我把?


搞什么调研影响我摸鱼


现在的我想拿枪打他


619c-hawmaua2753951.gif


拿弓箭射他


26a78036e0304df84daf3c634f264c0d.gif


点火烧他


c685-hawmaua2754245.gif


诶,如果我在3D场景中刻意加上一些框框,会不会看上去更立体呢?


方案一:造个框框,再打破它


现在我们用一个非常简单的立方体来试试看


2021-07-23 13_50_55.gif


2021-07-23 13_53_07.gif


立体感是稍微提升一点,但就这?那怕是交不了差的...


不过,大家发挥一下想象力,框框可以不全是直的,这个B站防遮挡弹幕是不是也让你产生了些裸眼3D的效果呢?


image.png


方案二:人脸识别


不行,谁都不能耽误我摸鱼。


此时我又想起另一个方案,是不是可以通过摄像头实时检测人脸在摄像头画面中的位置来模拟裸眼3D呢。我找到了tracking.js,这是一款在浏览器中可以实时进行人脸检测的库。


github.com/eduardolund…


var tracker = new tracking.ObjectTracker('face');
tracker.setInitialScale(4);
tracker.setStepSize(2);
tracker.setEdgesDensity(0.1);

tracking.track('#video', tracker, { camera: true });

tracker.on('track', function(event) {
context.clearRect(0, 0, canvas.width, canvas.height);

event.data.forEach(function(rect) {
context.strokeStyle = '#a64ceb';
context.strokeRect(rect.x, rect.y, rect.width, rect.height);
context.font = '11px Helvetica';
context.fillStyle = "#fff";
context.fillText('x: ' + rect.x + 'px', rect.x + rect.width + 5, rect.y + 11);
context.fillText('y: ' + rect.y + 'px', rect.x + rect.width + 5, rect.y + 22);
});
});

2021-07-23 14_45_40.gif


我们可以看到,画面中呈现了人脸在摄像头视角画布中的坐标,有了这个坐标数据,我们就可以做很多事情了。


接着把它接到threejs中,我们仍然拿这个立方体来试试看


2021-07-23 15_11_29.gif


实际体验还有点意思,但录屏的感受不太明显,请自行下载demo源码试试看吧


方案三:陀螺仪


W3C标准APIDeviceOrientation,用于检测移动设备的旋转方向和加速度。通过这个API,我们可以获取到三个基础属性:



  • alpha(设备平放时,水平旋转的角度)


image.png



  • beta(设备平放时,绕横向X轴旋转的角度)


image.png



  • gamma(设备平放时,绕纵向Y轴旋转的角度)


image.png


这个API的使用非常简单,通过给window添加一个监听


function capture_orientation (event) { 
var alpha = event.alpha;
var beta = event.beta;
var gamma = event.gamma;
console.log('Orientation - Alpha: '+alpha+', Beta: '+beta+', Gamma: '+gamma);
}

window.addEventListener('deviceorientation', capture_orientation, false);

现在我们把这个加入到咱们的立方体演示中,在加入的过程中,这里需要注意的是,在IOS设备上,这个API需要主动申请用户权限。


window.DeviceOrientationEvent.requestPermission()
.then(state => {
switch (state) {
case "granted":
//在这里建立监听
window.addEventListener('deviceorientation', capture_orientation, false);
break;
case "denied":
alert("你拒绝了使用陀螺仪");
break;
case "prompt":
alert("其他行为");
break;
}
});

返回的是一个promise,所以你也可以这么写


var permissionState = await window.DeviceOrientationEvent.requestPermission();
if(permissionState=="granted")window.addEventListener('deviceorientation', capture_orientation, false);

还有几点需要注意的事,requestPermission必须由用户主动发起,也就是必须在用户的行为事件里触发,比如“click”,还有就是这个API的调用,必须在HTTPS协议访问的网页里使用。


2021-07-25 10_46_16.gif


结语


至此,我能想到在网页里实现裸眼3D的几种方法都在此文中,你还能想到别的方法吗?请在评论区一起讨论吧。



链接:https://juejin.cn/post/6988949401842483230

收起阅读 »

感谢 compose 函数,让我的代码屎山?逐渐美丽了起来~

有言在先 本瓜知道前不久写的《JS 如何函数式编程》系列各位可能并不感冒,因为一切理论的东西如果脱离实战的话,那就将毫无意义。 于是乎,本瓜着手于实际工作开发,尝试应用函数式编程的一些思想。 最终惊人的发现:这个实现过程并不难,但是效果却不小! 实现思路:借...
继续阅读 »

有言在先


本瓜知道前不久写的《JS 如何函数式编程》系列各位可能并不感冒,因为一切理论的东西如果脱离实战的话,那就将毫无意义。


I6cDpC.th.png


于是乎,本瓜着手于实际工作开发,尝试应用函数式编程的一些思想。


最终惊人的发现:这个实现过程并不难,但是效果却不小!


实现思路:借助 compose 函数对连续的异步过程进行组装,不同的组合方式实现不同的业务流程。


这样不仅提高了代码的可读性,还提高了代码的扩展性。我想:这也许就是高内聚、低耦合吧~


撰此篇记之,并与各位分享。


场景说明


在和产品第一次沟通了需求后,我理解需要实现一个应用 新建流程,具体是这样的:


第 1 步:调用 sso 接口,拿到返回结果 res_token;


第 2 步:调用 create 接口,拿到返回结果 res_id;


第 3 步:处理字符串,拼接 Url;


第 4 步:建立 websocket 链接;


第 5 步:拿到 websocket 后端推送关键字,渲染页面;



  • 注:接口、参数有做一定简化


上面除了第 3 步、第 5 步,剩下的都是要调接口的,并且前后步骤都有传参的需要,可以理解为一个连续且有序的异步调用过程。


为了快速响应产品需求,于是本瓜迅速写出了以下代码:


/**
* 新建流程
* @param {*} appId
* @param {*} tag
*/

export const handleGetIframeSrc = function(appId, tag) {
let h5Id
// 第 1 步: 调用 sso 接口,获取token
getsingleSignOnToken({ formSource: tag }).then(data => {
return new Promise((resolve, reject) => {
resolve(data.result)
})
}).then(token => {
const para = { appId: appId }
return new Promise((resolve, reject) => {
// 第 2 步: 调用 create 接口,新建应用
appH5create(para).then(res => {
// 第 3 步: 处理字符串,拼接 Url
this.handleInsIframeUrl(res, token, appId)
this.setH5Id(res.result.h5Id)
h5Id = res.result.h5Id
resolve(h5Id)
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
})
}).then(h5Id => {
// 第 4 步:建立 websocket 链接;
return new Promise((resolve, reject) => {
webSocketInit(resolve, reject, h5Id)
})
}).then(doclose => {
// 第 5 步:拿到 websocket 后端推送关键字,渲染页面;
if (doclose) { this.setShowEditLink({ appId: appId, h5Id: h5Id, state: true }) }
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
}

const handleInsIframeUrl = function(res, token, appId) {
// url 拼接
const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
let editUrl = res.result.editUrl
const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
editUrl = res.result.editUrl.replace(infoId, `from=a2p&${infoId}`)
const headList = JSON.parse(JSON.stringify(this.headList))
headList.forEach(i => {
if (i.appId === appId) { i.srcUrl = `${editUrl}&token=${token}&secretId=${secretId}` }
})
this.setHeadList(headList)
}

这段代码是非常自然地根据产品所提需求,然后自己理解所编写。


其实还可以,是吧?🐶


需求更新


但你不得不承认,程序员和产品之间有一条无法逾越的沟通鸿沟


它大部分是由所站角度不同而产生,只能说:李姐李姐!


所以,基于前一个场景,需求发生了点 更新 ~


I6UGrz.th.png


除了上节所提的 【新建流程】 ,还要加一个 【编辑流程】 ╮(╯▽╰)╭


编辑流程简单来说就是:砍掉新建流程的第 2 步调接口,再稍微调整传参即可。


于是本瓜直接 copy 一下再作简单删改,不到 1 分钟,编辑流程的代码就诞生了~


/**
* 编辑流程
*/

const handleToIframeEdit = function() { // 编辑 iframe
const { editUrl, appId, h5Id } = this.ruleForm
// 第 1 步: 调用 sso 接口,获取token
getsingleSignOnToken({ formSource: 'ins' }).then(data => {
return new Promise((resolve, reject) => {
resolve(data.result)
})
}).then(token => {
// 第 2 步:处理字符串,拼接 Url
return new Promise((resolve, reject) => {
const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
const URL = editUrl.replace(infoId, `from=a2p&${infoId}`)
const headList = JSON.parse(JSON.stringify(this.headList))
headList.forEach(i => {
if (i.appId === appId) { i.srcUrl = `${URL}&token=${token}&secretId=${secretId}` }
})
this.setHeadList(headList)
this.setShowEditLink({ appId: appId, h5Id: h5Id, state: false })
this.setShowNavIframe({ appId: appId, state: true })
this.setNavLabel(this.headList.find(i => i.appId === appId).name)
resolve(h5Id)
})
}).then(h5Id => {
// 第 3 步:建立 websocket 链接;
return new Promise((resolve, reject) => {
webSocketInit(resolve, reject, h5Id)
})
}).then(doclose => {
// 第 4 步:拿到 websocket 后端推送关键字,渲染页面;
if (doclose) { this.setShowEditLink({ appId: appId, h5Id: h5Id, state: true }) }
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
}

需求再更新


老实讲,不怪产品,咱做需求的过程也是逐步理解需求的过程。理解有变化,再正常不过!(#^.^#) 李姐李姐......


I6UIKu.th.png


上面已有两个流程:新建流程、编辑流程


这次,要再加一个 重新创建流程 ~


重新创建流程可简单理解为:在新建流程之前调一个 delDraft 删除草稿接口;


至此,我们产生了三个流程:



  1. 新建流程;

  2. 编辑流程;

  3. 重新创建流程;


本瓜这里作个简单的脑图示意逻辑:


I6Xi9Q.png


我的直觉告诉我:不能再 copy 一份新建流程作修改了,因为这样就太拉了。。。没错,它没有耦合,但是它也没有内聚,这不是我想要的。于是,我开始封装了......


实现上述脑图的代码:


/**
* 判断是否存在草稿记录?
*/
judgeIfDraftExist(item) {
const para = { appId: item.appId }
return appH5ifDraftExist(para).then(res => {
const { editUrl, h5Id, version } = res.result
if (h5Id === -1) { // 不存在草稿
this.handleGetIframeSrc(item)
} else { // 存在草稿
this.handleExitDraft(item, h5Id, version, editUrl)
}
}).catch(err => {
console.log(err)
})
},
/**
* 选择继续编辑?
*/
handleExitDraft(item, h5Id, version, editUrl) {
this.$confirm('有未完成的信息收集链接,是否继续编辑?', '提示', {
confirmButtonText: '继续编辑',
cancelButtonText: '重新创建',
type: 'warning'
}).then(() => {
const editUrlH5Id = h5Id
this.handleGetIframeSrc(item, editUrl, editUrlH5Id)
}).catch(() => {
this.handleGetIframeSrc(item)
appH5delete({ h5Id: h5Id, version: version })
})
},
/**
* 新建流程、编辑流程、重新创建流程;
*/
handleGetIframeSrc(item, editUrl, editUrlH5Id) {
let ws_h5Id
getsingleSignOnToken({ formSource: item.tag }).then(data => {
// 调用 sso 接口,拿到返回结果 res_token;
return new Promise((resolve, reject) => {
resolve(data.result)
})
}).then(token => {
const para = { appId: item.appId }
return new Promise((resolve, reject) => {
if (!editUrl) { // 新建流程、重新创建流程
// 调用 create 接口,拿到返回结果 res_id;
appH5create(para).then(res => {
// 处理字符串,拼接 Url;
this.handleInsIframeUrl(res.result.editUrl, token, item.appId)
this.setH5Id(res.result.h5Id)
ws_h5Id = res.result.h5Id
this.setShowNavIframe({ appId: item.appId, state: true })
this.setNavLabel(item.name)
resolve(true)
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
} else { // 编辑流程
this.handleInsIframeUrl(editUrl, token, item.appId)
this.setH5Id(editUrlH5Id)
ws_h5Id = editUrlH5Id
this.setShowNavIframe({ appId: item.appId, state: true })
this.setNavLabel(item.name)
resolve(true)
}
})
}).then(() => {
// 建立 websocket 链接;
return new Promise((resolve, reject) => {
webSocketInit(resolve, reject, ws_h5Id)
})
}).then(doclose => {
// 拿到 websocket 后端推送关键字,渲染页面;
if (doclose) { this.setShowEditLink({ appId: item.appId, h5Id: ws_h5Id, state: true }) }
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
},

handleInsIframeUrl(editUrl, token, appId) {
// url 拼接
const secretId = this.$store.state.userinfo.enterpriseList[0].secretId
const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
const url = editUrl.replace(infoId, `from=a2p&${infoId}`)
const headList = JSON.parse(JSON.stringify(this.headList))
headList.forEach(i => {
if (i.appId === appId) { i.srcUrl = `${url}&token=${token}&secretId=${secretId}` }
})
this.setHeadList(headList)
}

如此,我们便将 新建流程、编辑流程、重新创建流程 全部整合到了上述代码;


需求再再更新


上面的封装看起来似乎还不错,但是这时我害怕了!想到:如果这个时候,还要加流程或者改流程呢??? 我是打算继续用 if...else 叠加在那个主函数里面吗?还是打算直接 copy 一份再作删改?


我都能遇见它会充斥着各种判断,变量赋值、引用飞来飞去,最终成为一坨💩,没错,代码屎山的💩


我摸了摸左胸的左心房,它告诉我:“饶了接盘侠吧~”


于是乎,本瓜尝试引进了之前吹那么 nb 的函数式编程!它的能力就是让代码更可读,这是我所需要的!来吧!!展示!!


I6cPMf.png


compose 函数


我们在 《XDM,JS如何函数式编程?看这就够了!(三)》 这篇讲过函数组合 compose!没错,我们这次就要用到这个家伙!


还记得那句话吗?



组合 ———— 声明式数据流 ———— 是支撑函数式编程最重要的工具之一!



最基础的 compose 函数是这样的:


function compose(...fns) {
return function composed(result){
// 拷贝一份保存函数的数组
var list = fns.slice();
while (list.length > 0) {
// 将最后一个函数从列表尾部拿出
// 并执行它
result = list.pop()( result );
}
return result;
};
}

// ES6 箭头函数形式写法
var compose =
(...fns) =>
result => {
var list = fns.slice();
while (list.length > 0) {
// 将最后一个函数从列表尾部拿出
// 并执行它
result = list.pop()( result );
}
return result;
};

它能将一个函数调用的输出路由跳转到另一个函数的调用上,然后一直进行下去。


I6c6uy.png


我们不需关注黑盒子里面做了什么,只需关注:这个东西(函数)是什么!它需要我输入什么!它的输出又是什么!


composePromise


但上面提到的 compose 函数是组合同步操作,而在本篇的实战中,我们需要组合是异步函数!


于是它被改造成这样:


/**
* @param {...any} args
* @returns
*/

export const composePromise = function(...args) {
const init = args.pop()
return function(...arg) {
return args.reverse().reduce(function(sequence, func) {
return sequence.then(function(result) {
// eslint-disable-next-line no-useless-call
return func.call(null, result)
})
}, Promise.resolve(init.apply(null, arg)))
}
}

原理:Promise 可以指定一个 sequence,来规定一个执行 then 的过程,then 函数会等到执行完成后,再执行下一个 then 的处理。启动sequence 可以使用 Promise.resolve() 这个函数。构建 sequence 可以使用 reduce 。


我们再写一个小测试在控制台跑一下!


let compose = function(...args) {
const init = args.pop()
return function(...arg) {
return args.reverse().reduce(function(sequence, func) {
return sequence.then(function(result) {
return func.call(null, result)
})
}, Promise.resolve(init.apply(null, arg)))
}
}

let a = async() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('xhr1')
resolve('xhr1')
}, 5000)
})
}

let b = async() => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('xhr2')
resolve('xhr2')
}, 3000)
})
}
let steps = [a, b] // 从右向左执行
let composeFn = compose(...steps)

composeFn().then(res => { console.log(666) })

// xhr2
// xhr1
// 666

它会先执行 b ,3 秒后输出 "xhr2",再执行 a,5 秒后输出 "xhr1",最后输出 666


你也可以在控制台带参 debugger 试试,很有意思:


composeFn(1, 2).then(res => { console.log(66) })

逐渐美丽起来


测试通过!借助上面 composePromise 函数,我们更加有信心用函数式编程 composePromise 重构 我们的代码了。



  • 实际上,这个过程一点不费力~


实现如下:


/**
* 判断是否存在草稿记录?
*/
handleJudgeIfDraftExist(item) {
return appH5ifDraftExist({ appId: item.appId }).then(res => {
const { editUrl, h5Id, version } = res.result
h5Id === -1 ? this.compose_newAppIframe(item) : this.hasDraftConfirm(item, h5Id, editUrl, version)
}).catch(err => {
console.log(err)
})
},
/**
* 选择继续编辑?
*/
hasDraftConfirm(item, h5Id, editUrl, version) {
this.$confirm('有未完成的信息收集链接,是否继续编辑?', '提示', {
confirmButtonText: '继续编辑',
cancelButtonText: '重新创建',
type: 'warning'
}).then(() => {
this.compose_editAppIframe(item, h5Id, editUrl)
}).catch(() => {
this.compose_reNewAppIframe(item, h5Id, version)
})
},

敲黑板啦!画重点啦!


/**
* 新建应用流程
* 入参: item
* 输出:item
*/
compose_newAppIframe(...args) {
const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_appH5create, this.step_getsingleSignOnToken]
const handleCompose = composePromise(...steps)
handleCompose(...args)
},
/**
* 编辑应用流程
* 入参: item, draftH5Id, editUrl
* 输出:item
*/
compose_editAppIframe(...args) {
const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_getsingleSignOnToken]
const handleCompose = composePromise(...steps)
handleCompose(...args)
},
/**
* 重新创建流程
* 入参: item,draftH5Id,version
* 输出:item
*/
compose_reNewAppIframe(...args) {
const steps = [this.step_getDoclose, this.step_createWs, this.step_splitUrl, this.step_appH5create, this.step_getsingleSignOnToken, this.step_delDraftH5Id]
const handleCompose = composePromise(...steps)
handleCompose(...args)
},

我们通过 composePromise 执行不同的 steps,来依次执行(从右至左)里面的功能函数;你可以任意组合、增删或修改 steps 的子项,也可以任意组合出新的流程来应付产品。并且,它们都被封装在 compose_xxx 里面,相互独立,不会干扰外界其它流程。同时,传参也是非常清晰的,输入是什么!输出又是什么!一目了然!


对照脑图再看此段代码,不正是对我们需求实现的最好诠释吗?


对于一个阅读陌生代码的人来说,你得先告诉他逻辑是怎样的,然后再告诉他每个步骤的内部具体实现。这样才是合理的!


I6Xi9Q.png


功能函数(具体步骤内部实现):


/**
* 调用 sso 接口,拿到返回结果 res_token;
*/
step_getsingleSignOnToken(...args) {
const [item] = args.flat(Infinity)
return new Promise((resolve, reject) => {
getsingleSignOnToken({ formSource: item.tag }).then(data => {
resolve([...args, data.result]) // data.result 即 token
})
})
},
/**
* 调用 create 接口,拿到返回结果 res_id;
*/
step_appH5create(...args) {
const [item, token] = args.flat(Infinity)
return new Promise((resolve, reject) => {
appH5create({ appId: item.appId }).then(data => {
resolve([item, data.result.h5Id, data.result.editUrl, token])
}).catch(err => {
this.$message({
message: err.message || '出现错误',
type: 'error'
})
})
})
},
/**
* 调 delDraft 删除接口;
*/
step_delDraftH5Id(...args) {
const [item, h5Id, version] = args.flat(Infinity)
return new Promise((resolve, reject) => {
appH5delete({ h5Id: h5Id, version: version }).then(data => {
resolve(...args)
})
})
},
/**
* 处理字符串,拼接 Url;
*/
step_splitUrl(...args) {
const [item, h5Id, editUrl, token] = args.flat(Infinity)
const infoId = editUrl.substr(editUrl.indexOf('?') + 1, editUrl.length - editUrl.indexOf('?'))
const url = editUrl.replace(infoId, `from=a2p&${infoId}`)
const headList = JSON.parse(JSON.stringify(this.headList))
headList.forEach(i => {
if (i.appId === item.appId) { i.srcUrl = `${url}&token=${token}` }
})
this.setHeadList(headList)
this.setH5Id(h5Id)
this.setShowNavIframe({ appId: item.appId, state: true })
this.setNavLabel(item.name)
return [...args]
},
/**
* 建立 websocket 链接;
*/
step_createWs(...args) {
return new Promise((resolve, reject) => {
webSocketInit(resolve, reject, ...args)
})
},
/**
* 拿到 websocket 后端推送关键字,渲染页面;
*/
step_getDoclose(...args) {
const [item, h5Id, editUrl, token, doclose] = args.flat(Infinity)
if (doclose) { this.setShowEditLink({ appId: item.appId, h5Id: h5Id, state: true }) }
return new Promise((resolve, reject) => {
resolve(true)
})
},

功能函数的输入、输出也是清晰可见的。


至此,我们可以认为:借助 compose 函数,借助函数式编程,咱把业务需求流程进行了封装,明确了输入输出,让我们的代码更加可读了!可扩展性也更高了!这不就是高内聚、低耦合?!


I6UWZD.th.png


阶段总结


你问我什么是 JS 函数式编程实战?我只能说本篇完全就是出自工作中的实战!!!


这样导致本篇代码量可能有点多,但是这就是实打实的需求变化,代码迭代、改造的过程。(建议通篇把握、理解)


当然,这不是终点,代码重构这个过程应该是每时每刻都在进行着。


对于函数式编程,简单应用 compose 函数,这也只是一个起点!


已经讲过,偏函数、函数柯里化、函数组合、数组操作、时间状态、函数式编程库等等概念......我们将再接再厉得使用它们,把代码屎山进行分类、打包、清理!让它不断美丽起来!💩 => 👩‍🦰


以上,便是本次分享~ 都看到这里,不如点个赞吧👍👍👍


链接:https://juejin.cn/post/6989020415444123662

收起阅读 »

最优解前端面试题答法

1. JS事件冒泡和事件代理(委托) 1. 事件冒泡 会从当前触发的事件目标一级一级往上传递,依次触发,直到document为止。 <body> <div id="parentId"> 查看消息信息 <div id="chi...
继续阅读 »

1. JS事件冒泡和事件代理(委托)


1. 事件冒泡


会从当前触发的事件目标一级一级往上传递,依次触发,直到document为止。


<body>    <div id="parentId"> 查看消息信息 <div id="childId1"> 删除消息信息 </div>    </div></body><script>    let parent = document.getElementById('parentId');    let childId1 = document.getElementById('childId1');    parent.addEventListener('click', function () {        alert('查看消息信息');    }, false);    childId1.addEventListener('click', function () {        alert('删除消息信息');    }, false);     // 如出发消息列表里的删除按钮, 先执行了删除操作, 在向上冒泡执行‘ 查看消息信息’。        // 打印:删除消息信息 查看消息信息</script>

原生js取消事件冒泡


   try{
e.stopPropagation();//非IE浏览器
}
catch(e){
window.event.cancelBubble = true;//IE浏览器
}

vue.js取消事件冒泡


<div @click.stop="doSomething($event)">vue取消事件冒泡</div>

2. 事件代理(委托)


a. 为什么要用事件委托:


比如ul下有100个li,用for循环遍历所有的li,然后给它们添加事件,需要不断的与dom节点进行交互,访问dom的次数越多,引起浏览器重绘与重排的次数也就越多,就会延长整个页面的交互就绪时间,这就是为什么性能优化的主要思想之一就是减少DOM操作的原因;


如果要用事件委托,就会将所有的操作放到js程序里面,与dom的操作就只需要交互一次,这样就能大大的减少与dom的交互次数,提高性能;


b. 事件委托的原理


事件委托:利用事件冒泡的特性,将本应该注册在子元素上的处理事件注册在父元素上,这样点击子元素时发现其本身没有相应事件就到父元素上寻找作出相应。这样做的优势有:


1、减少DOM操作,提高性能。


2、随时可以添加子元素,添加的子元素会自动有相应的处理事件。


<div id="box">
<input type="button" id="add" value="添加" />
<input type="button" id="remove" value="删除" />
<input type="button" id="move" value="移动" />
<input type="button" id="select" value="选择" />
</div>
方式一:需要4次dom操作
window.onload = function () {
var Add = document.getElementById("add");
var Remove = document.getElementById("remove");
var Move = document.getElementById("move");
var Select = document.getElementById("select");
Add.onclick = function () { alert('添加'); }; Remove.onclick = function () { alert('删除'); }; Move.onclick = function () { alert('移动'); }; Select.onclick = function () { alert('选择'); } }
方式二:委托它们父级代为执行事件
window.onload = function(){
var oBox = document.getElementById("box");
oBox.onclick = function (ev) {
var ev = ev || window.event;
var target = ev.target || ev.srcElement;
if(target.nodeName.toLocaleLowerCase() == 'input'){
switch(target.id){
case 'add' :
alert('添加');
break;
case 'remove' :
alert('删除');
break;
case 'move' :
alert('移动');
break;
case 'select' :
alert('选择');
break;
}
}
}

}
用事件委托就可以只用一次dom操作就能完成所有的效果,比上面的性能肯定是要好一些的

3. 事件捕获


会从document开始触发,一级一级往下传递,依次触发,直到真正事件目标为止。


    <div> <button>            <p>点击捕获</p>        </button></div>    <script>        var oP = document.querySelector('p');        var oB = document.querySelector('button');        var oD = document.querySelector('div');        var oBody = document.querySelector('body');        oP.addEventListener('click', function () {            console.log('p标签被点击')        }, true);        oB.addEventListener('click', function () {            console.log("button被点击")        }, true);        oD.addEventListener('click', function () {            console.log('div被点击')        }, true);        oBody.addEventListener('click', function () {            console.log('body被点击')        }, true);    </script>    点击<p>点击捕获</p>,打印的顺序是:body=>div=>button=>p</body>

流程:先捕获,然后处理,然后再冒泡出去。


2. 原型链



1. 原型对象诞生原因和本质


为了解决无法共享公共属性的问题,所以要设计一个对象专门用来存储对象共享的属性,那么我们叫它「原型对象


原理:构造函数加一个属性叫做prototype,用来指向原型对象,我们把所有实例对象共享的属性和方法都放在这个构造函数的prototype属性指向的原型对象中,不需要共享的属性和方法放在构造函数中。实现构造函数生成的所有实例对象都能够共享属性。


构造函数:私有属性
原型对象:共有属性

2.  彼此之间的关系


构造函数中一属性prototype:指向原型对象,而原型对象一constructor属性,又指回了构造函数。

每个构造函数生成的实例对象都有一个proto属性,这个属性指向原型对象。


那原型对象的_proto_属性指向谁?-> null


3. 原型链是什么?


顾名思义,肯定是一条链,既然每个对象都有一个_proto_属性指向原型对象,那么原型对象也有_proto_指向原型对象的原型对象,直到指向上图中的null,这才到达原型链的顶端。


4. 原型链和继承使用场景


原型链主要用于继承,实现代码复用。因为js算不上是面向对象的语言,继承是基于原型实现而不是基于类实现的,


a. 判断函数的原型是否在对象的原型链上


对象 instanceof 函数(不推荐使用)


b. 创建一个新对象,该新对象的隐式原型指向指定的对象


Object.create(对象)


var obj = Object.create(Object.prototype);


obj.__proto__ === Object.prototype


c. new的实现


d. es6的class A extends B 


因为es6-没有类和继承的概念。js实现继承本质是把js中的对象构造函数在自己的脑中抽象成一个类,然后使用构造函数的protptype属性封装出一个类(另一个构造函数),使之完美继承前一构造函数的所有属性和方法。因为构造函数能new出一个具体的对象实例,这就在js中实现了现代化的面向对象和继承。


3. 闭包和垃圾回收机制


闭包的概念


  function f1(){    var n=999;    function f2(){      alert(n);    }    return f2;  }  var result=f1();  result(); // 999

在上面的代码中,函数f2就被包括在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。但是反过来就不行,f2内部的局部变量,对f1就是不可见的。这就是Javascript语言特有的"链式作用域"结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。


既然f2可以读取f1中的局部变量,那么只要把f2作为返回值,我们不就可以在f1外部读取它的内部变量了吗!


作用:一是前面提到的可以读取函数内部的变量,二是让这些变量的值始终保持在内存中,主要用来封装私有变量, 提供一些暴露的接口


垃圾回收


**垃圾回收机制:JavaScript 引擎中有一个后台进程称为垃圾回收器,它监视所有对象,并删除那些不可访问的对象

**


**注意点:**对于内存的管理,Javascript与C语言等底层语言JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理,


实现的原理:由于 f2 中引用了 相对于自己的全局变量 n ,所以 f2 会一直存在内存中,又因为 n 是 f1 中的局部变量,也就是说 f2 依赖 f1,所以说 f1 也会一直存在内存中,并不像普通函数那样,调用后变量便被垃圾回收了。


所以说,在setTimeout中的函数引用了外层 for循环的变量 i,导致 i 一直存在内存中,不被回收,所以等到JS队列执行 函数时,i 已经是 10了,所以最终打印 10个10。


五、使用闭包的注意点


1)由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。


for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
打印:5个6,原因js事件执行机制
办法一:
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})(i);
}
打印:依次输出1到5
原因:因为实际参数跟定时器内部的i有强依赖。通过闭包,将i的变量驻留在内存中,当输出j时,
引用的是外部函数的变量值i,i的值是根据循环来的,执行setTimeout时已经确定了里面的的输出了。办法二:
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
打印:依次输出1到5因为for循环头部的let不仅将i绑定到for循环中,事实上它将其重新绑定到循环体的每一次迭代中,
确保上一次迭代结束的值重新被赋值。
setTimeout里面的function()属于一个新的域,
通过var定义的变量是无法传入到这个函数执行域中的,
通过使用let来声明块变量能作用于这个块,所以function就能使用i这个变量了;
这个匿名函数的参数作用域和for参数的作用域不一样,是利用了这一点来完成的。
这个匿名函数的作用域有点类似类的属性,是可以被内层方法使用的。

4. js事件执行机制


事件循环的过程如下:



  1. JS引擎(唯一主线程)按顺序解析代码,遇到函数声明,直接跳过,遇到函数调用,入栈;

  2. 如果是同步函数调用,直接执行得到结果,同步函数弹出栈,继续下一个函数调用;

  3. 如果是异步函数调用,分发给Web API(多个辅助线程),异步函数弹出栈,继续下一个函数调用;

  4. Web API中,异步函数在相应辅助线程中处理完成后,即异步函数达到触发条件了(比如setTimeout设置的10s后),如果异步函数是宏任务,则入宏任务消息队列,如果是微任务,则入微任务消息队列;

  5. Event Loop不停地检查主线程的调用栈与回调队列,当调用栈空时,就把微任务消息队列中的第一个任务推入栈中执行,执行完成后,再取第二个微任务,直到微任务消息队列为空;然后

    去宏任务消息队列中取第一个宏任务推入栈中执行,当该宏任务执行完成后,在下一个宏任务执行前,再依次取出微任务消息队列中的所有微任务入栈执行。

  6. 上述过程不断循环,每当微任务队列清空,可作为本轮事件循环的结束。




链接:https://juejin.cn/post/6987429092542922783

收起阅读 »

项目中实用的前端性能优化

一、CDN 1. CDN的概念 CDN(Content Delivery Network,内容分发网络)是指一种通过互联网互相连接的电脑网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户,来提供高性能、可扩展性...
继续阅读 »

一、CDN


1. CDN的概念


CDN(Content Delivery Network,内容分发网络)是指一种通过互联网互相连接的电脑网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户,来提供高性能、可扩展性及低成本的网络内容传递给用户。


典型的CDN系统由下面三个部分组成:



  • 分发服务系统: 最基本的工作单元就是Cache设备,cache(边缘cache)负责直接响应最终用户的访问请求,把缓存在本地的内容快速地提供给用户。同时cache还负责与源站点进行内容同步,把更新的内容以及本地没有的内容从源站点获取并保存在本地。Cache设备的数量、规模、总服务能力是衡量一个CDN系统服务能力的最基本的指标。

  • 负载均衡系统: 主要功能是负责对所有发起服务请求的用户进行访问调度,确定提供给用户的最终实际访问地址。两级调度体系分为全局负载均衡(GSLB)和本地负载均衡(SLB)。全局负载均衡主要根据用户就近性原则,通过对每个服务节点进行“最优”判断,确定向用户提供服务的cache的物理位置。本地负载均衡主要负责节点内部的设备负载均衡

  • **运营管理系统:**运营管理系统分为运营管理和网络管理子系统,负责处理业务层面的与外界系统交互所必须的收集、整理、交付工作,包含客户管理、产品管理、计费管理、统计分析等功能。


2. CDN的作用


CDN一般会用来托管Web资源(包括文本、图片和脚本等),可供下载的资源(媒体文件、软件、文档等),应用程序(门户网站等)。使用CDN来加速这些资源的访问。


(1)在性能方面,引入CDN的作用在于:



  • 用户收到的内容来自最近的数据中心,延迟更低,内容加载更快

  • 部分资源请求分配给了CDN,减少了服务器的负载


(2)在安全方面,CDN有助于防御DDoS、MITM等网络攻击:



  • 针对DDoS:通过监控分析异常流量,限制其请求频率

  • 针对MITM:从源服务器到 CDN 节点到 ISP(Internet Service Provider),全链路 HTTPS 通信


除此之外,CDN作为一种基础的云服务,同样具有资源托管、按需扩展(能够应对流量高峰)等方面的优势。


3. CDN的原理


CDN和DNS有着密不可分的联系,先来看一下DNS的解析域名过程,在浏览器输入 http://www.test.com 的解析过程如下:


(1) 检查浏览器缓存


(2)检查操作系统缓存,常见的如hosts文件


(3)检查路由器缓存


(4)如果前几步都没没找到,会向ISP(网络服务提供商)的LDNS服务器查询


(5)如果LDNS服务器没找到,会向根域名服务器(Root Server)请求解析,分为以下几步:



  • 根服务器返回顶级域名(TLD)服务器如.com.cn.org等的地址,该例子中会返回.com的地址

  • 接着向顶级域名服务器发送请求,然后会返回次级域名(SLD)服务器的地址,本例子会返回.test的地址

  • 接着向次级域名服务器发送请求,然后会返回通过域名查询到的目标IP,本例子会返回http://www.test.com的地址

  • Local DNS Server会缓存结果,并返回给用户,缓存在系统中


CDN的工作原理:


(1)用户未使用CDN缓存资源的过程:



  1. 浏览器通过DNS对域名进行解析(就是上面的DNS解析过程),依次得到此域名对应的IP地址

  2. 浏览器根据得到的IP地址,向域名的服务主机发送数据请求

  3. 服务器向浏览器返回响应数据


(2)用户使用CDN缓存资源的过程:



  1. 对于点击的数据的URL,经过本地DNS系统的解析,发现该URL对应的是一个CDN专用的DNS服务器,DNS系统就会将域名解析权交给CNAME指向的CDN专用的DNS服务器。

  2. CND专用DNS服务器将CND的全局负载均衡设备IP地址返回给用户

  3. 用户向CDN的全局负载均衡设备发起数据请求

  4. CDN的全局负载均衡设备根据用户的IP地址,以及用户请求的内容URL,选择一台用户所属区域的区域负载均衡设备,告诉用户向这台设备发起请求

  5. 区域负载均衡设备选择一台合适的缓存服务器来提供服务,将该缓存服务器的IP地址返回给全局负载均衡设备

  6. 全局负载均衡设备把服务器的IP地址返回给用户

  7. 用户向该缓存服务器发起请求,缓存服务器响应用户的请求,将用户所需内容发送至用户终端。


如果缓存服务器没有用户想要的内容,那么缓存服务器就会向它的上一级缓存服务器请求内容,以此类推,直到获取到需要的资源。最后如果还是没有,就会回到自己的服务器去获取资源。


image


CNAME(意为:别名):在域名解析中,实际上解析出来的指定域名对应的IP地址,或者该域名的一个CNAME,然后再根据这个CNAME来查找对应的IP地址。


4. CDN的使用场景



  • **使用第三方的CDN服务:**如果想要开源一些项目,可以使用第三方的CDN服务

  • **使用CDN进行静态资源的缓存:**将自己网站的静态资源放在CDN上,比如js、css、图片等。可以将整个项目放在CDN上,完成一键部署。

  • **直播传送:**直播本质上是使用流媒体进行传送,CDN也是支持流媒体传送的,所以直播完全可以使用CDN来提高访问速度。CDN在处理流媒体的时候与处理普通静态文件有所不同,普通文件如果在边缘节点没有找到的话,就会去上一层接着寻找,但是流媒体本身数据量就非常大,如果使用回源的方式,必然会带来性能问题,所以流媒体一般采用的都是主动推送的方式来进行。


二、懒加载


1. 懒加载的概念


懒加载也叫做延迟加载、按需加载,指的是在长网页中延迟加载图片数据,是一种较好的网页性能优化的方式。在比较长的网页或应用中,如果图片很多,所有的图片都被加载出来,而用户只能看到可视窗口的那一部分图片数据,这样就浪费了性能。


如果使用图片的懒加载就可以解决以上问题。在滚动屏幕之前,可视化区域之外的图片不会进行加载,在滚动屏幕时才加载。这样使得网页的加载速度更快,减少了服务器的负载。懒加载适用于图片较多,页面列表较长(长列表)的场景中。


2. 懒加载的特点



  • 减少无用资源的加载:使用懒加载明显减少了服务器的压力和流量,同时也减小了浏览器的负担。

  • 提升用户体验: 如果同时加载较多图片,可能需要等待的时间较长,这样影响了用户体验,而使用懒加载就能大大的提高用户体验。

  • 防止加载过多图片而影响其他资源文件的加载 :会影响网站应用的正常使用。


3. 懒加载的实现原理


图片的加载是由src引起的,当对src赋值时,浏览器就会请求图片资源。根据这个原理,我们使用HTML5 的data-xxx属性来储存图片的路径,在需要加载图片的时候,将data-xxx中图片的路径赋值给src,这样就实现了图片的按需加载,即懒加载。


注意:data-xxx 中的xxx可以自定义,这里我们使用data-src来定义。


懒加载的实现重点在于确定用户需要加载哪张图片,在浏览器中,可视区域内的资源就是用户需要的资源。所以当图片出现在可视区域时,获取图片的真实地址并赋值给图片即可。


使用原生JavaScript实现懒加载:


知识点:


(1)window.innerHeight 是浏览器可视区的高度


(2)document.body.scrollTop || document.documentElement.scrollTop 是浏览器滚动的过的距离


(3)imgs.offsetTop 是元素顶部距离文档顶部的高度(包括滚动条的距离)


(4)图片加载条件:img.offsetTop < window.innerHeight + document.body.scrollTop;


图示:


image


代码实现:


<div>
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
</div>
<script>
var imgs = document.querySelectorAll('img');
function lozyLoad(){
var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
var winHeight= window.innerHeight;
for(var i=0;i < imgs.length;i++){
if(imgs[i].offsetTop < scrollTop + winHeight ){
imgs[i].src = imgs[i].getAttribute('data-src');
}
}
}
window.onscroll = lozyLoad;
</script>

4. 懒加载与预加载的区别


这两种方式都是提高网页性能的方式,两者主要区别是一个是提前加载,一个是迟缓甚至不加载。懒加载对服务器前端有一定的缓解压力作用,预加载则会增加服务器前端压力。



  • 懒加载也叫延迟加载,指的是在长网页中延迟加载图片的时机,当用户需要访问时,再去加载,这样可以提高网站的首屏加载速度,提升用户的体验,并且可以减少服务器的压力。它适用于图片很多,页面很长的电商网站的场景。懒加载的实现原理是,将页面上的图片的 src 属性设置为空字符串,将图片的真实路径保存在一个自定义属性中,当页面滚动的时候,进行判断,如果图片进入页面可视区域内,则从自定义属性中取出真实路径赋值给图片的 src 属性,以此来实现图片的延迟加载。

  • 预加载指的是将所需的资源提前请求加载到本地,这样后面在需要用到时就直接从缓存取资源。 通过预加载能够减少用户的等待时间,提高用户的体验。我了解的预加载的最常用的方式是使用 js 中的 image 对象,通过为 image 对象来设置 scr 属性,来实现图片的预加载。


三、回流与重绘


1. 回流与重绘的概念及触发条件


(1)回流


当渲染树中部分或者全部元素的尺寸、结构或者属性发生变化时,浏览器会重新渲染部分或者全部文档的过程就称为回流


下面这些操作会导致回流:



  • 页面的首次渲染

  • 浏览器的窗口大小发生变化

  • 元素的内容发生变化

  • 元素的尺寸或者位置发生变化

  • 元素的字体大小发生变化

  • 激活CSS伪类

  • 查询某些属性或者调用某些方法

  • 添加或者删除可见的DOM元素


在触发回流(重排)的时候,由于浏览器渲染页面是基于流式布局的,所以当触发回流时,会导致周围的DOM元素重新排列,它的影响范围有两种:



  • 全局范围:从根节点开始,对整个渲染树进行重新布局

  • 局部范围:对渲染树的某部分或者一个渲染对象进行重新布局


(2)重绘


当页面中某些元素的样式发生变化,但是不会影响其在文档流中的位置时,浏览器就会对元素进行重新绘制,这个过程就是重绘


下面这些操作会导致回流:



  • color、background 相关属性:background-color、background-image 等

  • outline 相关属性:outline-color、outline-width 、text-decoration

  • border-radius、visibility、box-shadow


注意: 当触发回流时,一定会触发重绘,但是重绘不一定会引发回流。


2. 如何避免回流与重绘?


减少回流与重绘的措施:



  • 操作DOM时,尽量在低层级的DOM节点进行操作

  • 不要使用table布局, 一个小的改动可能会使整个table进行重新布局

  • 使用CSS的表达式

  • 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。

  • 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素

  • 避免频繁操作DOM,可以创建一个文档片段documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中

  • 将元素先设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。

  • 将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制


浏览器针对页面的回流与重绘,进行了自身的优化——渲染队列


浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。


上面,将多个读操作(或者写操作)放在一起,就会等所有的读操作进入队列之后执行,这样,原本应该是触发多次回流,变成了只触发一次回流。


3. 如何优化动画?


对于如何优化动画,我们知道,一般情况下,动画需要频繁的操作DOM,就就会导致页面的性能问题,我们可以将动画的position属性设置为absolute或者fixed,将动画脱离文档流,这样他的回流就不会影响到页面了。


4. documentFragment 是什么?用它跟直接操作 DOM 的区别是什么?


MDN中对documentFragment的解释:



DocumentFragment,文档片段接口,一个没有父对象的最小文档对象。它被作为一个轻量版的 Document使用,就像标准的document一样,存储由节点(nodes)组成的文档结构。与document相比,最大的区别是DocumentFragment不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会导致性能等问题。



当我们把一个 DocumentFragment 节点插入文档树时,插入的不是 DocumentFragment 自身,而是它的所有子孙节点。在频繁的DOM操作时,我们就可以将DOM元素插入DocumentFragment,之后一次性的将所有的子孙节点插入文档中。和直接操作DOM相比,将DocumentFragment 节点插入DOM树时,不会触发页面的重绘,这样就大大提高了页面的性能。


四、节流与防抖


1. 对节流与防抖的理解



  • 函数防抖是指在事件被触发 n 秒后再执行回调,如果在这 n 秒内事件又被触发,则重新计时。这可以使用在一些点击请求的事件上,避免因为用户的多次点击向后端发送多次请求。

  • 函数节流是指规定一个单位时间,在这个单位时间内,只能有一次触发事件的回调函数执行,如果在同一个单位时间内某事件被触发多次,只有一次能生效。节流可以使用在 scroll 函数的事件监听上,通过事件节流来降低事件调用的频率。


防抖函数的应用场景:



  • 按钮提交场景:防⽌多次提交按钮,只执⾏最后提交的⼀次

  • 服务端验证场景:表单验证需要服务端配合,只执⾏⼀段连续的输⼊事件的最后⼀次,还有搜索联想词功能类似⽣存环境请⽤lodash.debounce


节流函数的****适⽤场景:



  • 拖拽场景:固定时间内只执⾏⼀次,防⽌超⾼频次触发位置变动

  • 缩放场景:监控浏览器resize

  • 动画场景:避免短时间内多次触发动画引起性能问题


2. 实现节流函数和防抖函数


函数防抖的实现:


function debounce(fn, wait) {
var timer = null;

return function() {
var context = this,
args = [...arguments];

// 如果此时存在定时器的话,则取消之前的定时器重新记时
if (timer) {
clearTimeout(timer);
timer = null;
}

// 设置定时器,使事件间隔指定事件后执行
timer = setTimeout(() => {
fn.apply(context, args);
}, wait);
};
}

函数节流的实现:


// 时间戳版
function throttle(fn, delay) {
var preTime = Date.now();

return function() {
var context = this,
args = [...arguments],
nowTime = Date.now();

// 如果两次时间间隔超过了指定时间,则执行函数。
if (nowTime - preTime >= delay) {
preTime = Date.now();
return fn.apply(context, args);
}
};
}

// 定时器版
function throttle (fun, wait){
let timeout = null
return function(){
let context = this
let args = [...arguments]
if(!timeout){
timeout = setTimeout(() => {
fun.apply(context, args)
timeout = null
}, wait)
}
}
}

五、图片优化


1. 如何对项目中的图片进行优化?



  1. 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。

  2. 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。

  3. 小图使用 base64 格式

  4. 将多个图标文件整合到一张图片中(雪碧图)

  5. 选择正确的图片格式:





    • 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好

    • 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替

    • 照片使用 JPEG




2. 常见的图片格式及使用场景


(1)BMP,是无损的、既支持索引色也支持直接色的点阵图。这种图片格式几乎没有对数据进行压缩,所以BMP格式的图片通常是较大的文件。


(2)GIF是无损的、采用索引色的点阵图。采用LZW压缩算法进行编码。文件小,是GIF格式的优点,同时,GIF格式还具有支持动画以及透明的优点。但是GIF格式仅支持8bit的索引色,所以GIF格式适用于对色彩要求不高同时需要文件体积较小的场景。


(3)JPEG是有损的、采用直接色的点阵图。JPEG的图片的优点是采用了直接色,得益于更丰富的色彩,JPEG非常适合用来存储照片,与GIF相比,JPEG不适合用来存储企业Logo、线框类的图。因为有损压缩会导致图片模糊,而直接色的选用,又会导致图片文件较GIF更大。


(4)PNG-8是无损的、使用索引色的点阵图。PNG是一种比较新的图片格式,PNG-8是非常好的GIF格式替代者,在可能的情况下,应该尽可能的使用PNG-8而不是GIF,因为在相同的图片效果下,PNG-8具有更小的文件体积。除此之外,PNG-8还支持透明度的调节,而GIF并不支持。除非需要动画的支持,否则没有理由使用GIF而不是PNG-8。


(5)PNG-24是无损的、使用直接色的点阵图。PNG-24的优点在于它压缩了图片的数据,使得同样效果的图片,PNG-24格式的文件大小要比BMP小得多。当然,PNG24的图片还是要比JPEG、GIF、PNG-8大得多。


(6)SVG是无损的矢量图。SVG是矢量图意味着SVG图片由直线和曲线以及绘制它们的方法组成。当放大SVG图片时,看到的还是线和曲线,而不会出现像素点。这意味着SVG图片在放大时,不会失真,所以它非常适合用来绘制Logo、Icon等。


(7)WebP是谷歌开发的一种新图片格式,WebP是同时支持有损和无损压缩的、使用直接色的点阵图。从名字就可以看出来它是为Web而生的,什么叫为Web而生呢?就是说相同质量的图片,WebP具有更小的文件体积。现在网站上充满了大量的图片,如果能够降低每一个图片的文件大小,那么将大大减少浏览器和服务器之间的数据传输量,进而降低访问延迟,提升访问体验。目前只有Chrome浏览器和Opera浏览器支持WebP格式,兼容性不太好。



  • 在无损压缩的情况下,相同质量的WebP图片,文件大小要比PNG小26%;

  • 在有损压缩的情况下,具有相同图片精度的WebP图片,文件大小要比JPEG小25%~34%;

  • WebP图片格式支持图片透明度,一个无损压缩的WebP图片,如果要支持透明度只需要22%的格外文件大小。


六、Webpack优化


1. 如何提⾼webpack的打包速度**?**


(1)优化 Loader


对于 Loader 来说,影响打包效率首当其冲必属 Babel 了。因为 Babel 会将代码转为字符串生成 AST,然后对 AST 继续进行转变最后再生成新的代码,项目越大,转换代码越多,效率就越低。当然了,这是可以优化的。


首先我们优化 Loader 的文件搜索范围


module.exports = {
module: {
rules: [
{
// js 文件才使用 babel
test: /\.js$/,
loader: 'babel-loader',
// 只在 src 文件夹下查找
include: [resolve('src')],
// 不会去查找的路径
exclude: /node_modules/
}
]
}
}

对于 Babel 来说,希望只作用在 JS 代码上的,然后 node_modules 中使用的代码都是编译过的,所以完全没有必要再去处理一遍。


当然这样做还不够,还可以将 Babel 编译过的文件缓存起来,下次只需要编译更改过的代码文件即可,这样可以大幅度加快打包时间


loader: 'babel-loader?cacheDirectory=true'

(2)HappyPack


受限于 Node 是单线程运行的,所以 Webpack 在打包的过程中也是单线程的,特别是在执行 Loader 的时候,长时间编译的任务很多,这样就会导致等待的情况。


HappyPack 可以将 Loader 的同步执行转换为并行的,这样就能充分利用系统资源来加快打包效率了


module: {
loaders: [
{
test: /\.js$/,
include: [resolve('src')],
exclude: /node_modules/,
// id 后面的内容对应下面
loader: 'happypack/loader?id=happybabel'
}
]
},
plugins: [
new HappyPack({
id: 'happybabel',
loaders: ['babel-loader?cacheDirectory'],
// 开启 4 个线程
threads: 4
})
]

(3)DllPlugin


DllPlugin 可以将特定的类库提前打包然后引入。这种方式可以极大的减少打包类库的次数,只有当类库更新版本才有需要重新打包,并且也实现了将公共代码抽离成单独文件的优化方案。DllPlugin的使用方法如下:


// 单独配置在一个文件中
// webpack.dll.conf.js
const path = require('path')
const webpack = require('webpack')
module.exports = {
entry: {
// 想统一打包的类库
vendor: ['react']
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].dll.js',
library: '[name]-[hash]'
},
plugins: [
new webpack.DllPlugin({
// name 必须和 output.library 一致
name: '[name]-[hash]',
// 该属性需要与 DllReferencePlugin 中一致
context: __dirname,
path: path.join(__dirname, 'dist', '[name]-manifest.json')
})
]
}

然后需要执行这个配置文件生成依赖文件,接下来需要使用 DllReferencePlugin 将依赖文件引入项目中


// webpack.conf.js
module.exports = {
// ...省略其他配置
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
// manifest 就是之前打包出来的 json 文件
manifest: require('./dist/vendor-manifest.json'),
})
]
}

(4)代码压缩


在 Webpack3 中,一般使用 UglifyJS 来压缩代码,但是这个是单线程运行的,为了加快效率,可以使用 webpack-parallel-uglify-plugin 来并行运行 UglifyJS,从而提高效率。


在 Webpack4 中,不需要以上这些操作了,只需要将 mode 设置为 production 就可以默认开启以上功能。代码压缩也是我们必做的性能优化方案,当然我们不止可以压缩 JS 代码,还可以压缩 HTML、CSS 代码,并且在压缩 JS 代码的过程中,我们还可以通过配置实现比如删除 console.log 这类代码的功能。


(5)其他


可以通过一些小的优化点来加快打包速度



  • resolve.extensions:用来表明文件后缀列表,默认查找顺序是 ['.js', '.json'],如果你的导入文件没有添加后缀就会按照这个顺序查找文件。我们应该尽可能减少后缀列表长度,然后将出现频率高的后缀排在前面

  • resolve.alias:可以通过别名的方式来映射一个路径,能让 Webpack 更快找到路径

  • module.noParse:如果你确定一个文件下没有其他依赖,就可以使用该属性让 Webpack 不扫描该文件,这种方式对于大型的类库很有帮助


2. 如何减少 Webpack 打包体积


(1)按需加载


在开发 SPA 项目的时候,项目中都会存在很多路由页面。如果将这些页面全部打包进一个 JS 文件的话,虽然将多个请求合并了,但是同样也加载了很多并不需要的代码,耗费了更长的时间。那么为了首页能更快地呈现给用户,希望首页能加载的文件体积越小越好,这时候就可以使用按需加载,将每个路由页面单独打包为一个文件。当然不仅仅路由可以按需加载,对于 loadash 这种大型类库同样可以使用这个功能。


按需加载的代码实现这里就不详细展开了,因为鉴于用的框架不同,实现起来都是不一样的。当然了,虽然他们的用法可能不同,但是底层的机制都是一样的。都是当使用的时候再去下载对应文件,返回一个 Promise,当 Promise 成功以后去执行回调。


(2)Scope Hoisting


Scope Hoisting 会分析出模块之间的依赖关系,尽可能的把打包出来的模块合并到一个函数中去。


比如希望打包两个文件:


// test.js
export const a = 1
// index.js
import { a } from './test.js'

对于这种情况,打包出来的代码会类似这样:


[
/* 0 */
function (module, exports, require) {
//...
},
/* 1 */
function (module, exports, require) {
//...
}
]

但是如果使用 Scope Hoisting ,代码就会尽可能的合并到一个函数中去,也就变成了这样的类似代码:


[
/* 0 */
function (module, exports, require) {
//...
}
]

这样的打包方式生成的代码明显比之前的少多了。如果在 Webpack4 中你希望开启这个功能,只需要启用 optimization.concatenateModules 就可以了:


module.exports = {
optimization: {
concatenateModules: true
}
}

(3)Tree Shaking


Tree Shaking 可以实现删除项目中未被引用的代码,比如:


// test.js
export const a = 1
export const b = 2
// index.js
import { a } from './test.js'

对于以上情况,test 文件中的变量 b 如果没有在项目中使用到的话,就不会被打包到文件中。


如果使用 Webpack 4 的话,开启生产环境就会自动启动这个优化功能。


3. 如何⽤webpack来优化前端性能?


⽤webpack优化前端性能是指优化webpack的输出结果,让打包的最终结果在浏览器运⾏快速⾼效。



  • 压缩代码:删除多余的代码、注释、简化代码的写法等等⽅式。可以利⽤webpack的 UglifyJsPlugin 和 ParallelUglifyPlugin 来压缩JS⽂件, 利⽤ cssnano (css-loader?minimize)来压缩css

  • 利⽤CDN加速: 在构建过程中,将引⽤的静态资源路径修改为CDN上对应的路径。可以利⽤webpack对于 output 参数和各loader的 publicPath 参数来修改资源路径

  • Tree Shaking: 将代码中永远不会⾛到的⽚段删除掉。可以通过在启动webpack时追加参数 --optimize-minimize 来实现

  • Code Splitting: 将代码按路由维度或者组件分块(chunk),这样做到按需加载,同时可以充分利⽤浏览器缓存

  • 提取公共第三⽅库: SplitChunksPlugin插件来进⾏公共模块抽取,利⽤浏览器缓存可以⻓期缓存这些⽆需频繁变动的公共代码


4. 如何提⾼webpack的构建速度?



  1. 多⼊⼝情况下,使⽤ CommonsChunkPlugin 来提取公共代码

  2. 通过 externals 配置来提取常⽤库

  3. 利⽤ DllPlugin 和 DllReferencePlugin 预编译资源模块 通过 DllPlugin 来对那些我们引⽤但是绝对不会修改的npm包来进⾏预编译,再通过 DllReferencePlugin 将预编译的模块加载进来。

  4. 使⽤ Happypack 实现多线程加速编译

  5. 使⽤ webpack-uglify-parallel 来提升 uglifyPlugin 的压缩速度。 原理上 webpack-uglify-parallel 采⽤了多核并⾏压缩来提升压缩速度

  6. 使⽤ Tree-shaking 和 Scope Hoisting 来剔除多余代码

链接:https://juejin.cn/post/6987268877096845320

收起阅读 »

『前端BUG』—— 本地代理导致会话cookie中的数据丢失

vue
问题在本地用代理请求服务端接口,解决跨域问题后,发生了一件极其诡异的事情,明明登录成功了,但是请求每个接口都返回未登录的报错信息。原因该套系统是采用会话cookie进行登录用户的身份认证,故查看每个请求的Request Headers中的cookie的值,发现...
继续阅读 »

问题

在本地用代理请求服务端接口,解决跨域问题后,发生了一件极其诡异的事情,明明登录成功了,但是请求每个接口都返回未登录的报错信息。

原因

该套系统是采用会话cookie进行登录用户的身份认证,故查看每个请求的Request Headers中的cookie的值,发现原本如下图中的红框区域的SESSION不见了。

image.png

而明明登录接口的Response Headers中是存在set-cookie。

image.png

set-cookie会是把其值中的SESSION存储到浏览器的cookie中,存储成功后,每次请求服务端时,都会去浏览器中的cookie中读取SESSION,然后通过Request Headers中的cookie传递到服务端,完成身份认证。

另外set-cookie的值是服务端设置的,我们来认真观察一下set-cookie的值

SESSION=NjE1MTNmZWI1N2ExNDYyZGE4MWE0YmZjNjgwMmFmZGY=; Path=/api/operation/; HttpOnly; SameSite=Lax
复制代码

里面除SESSION,还有PathHttpOnlySameSite,而Path就是导致SESSION无法存储到客户端中的元凶,其中Path的值/api/operation/表示该cookie只有在用请求路径的前缀为/api/operation/才能使用。

回到代理配置中一看,

proxy: getProxy({
'/dev': {
target: 'https://xxx.xxx.com',
pathRewrite: { '^/dev': '/api' },
secure: false,
changeOrigin: true
}
}),
复制代码

代理后,请求服务端的地址为xxx.xxx.com/dev/operati… ,其路径为 dev/operation/xxx,自然与/api/operation/不匹配,导致该cookie无法使用,自然无法将SESSION保存到浏览器的cookie中。

解决

找到原因了,问题很好解决,只要更改一下代理配置。

proxy: getProxy({
'/api': {
target: 'https://xxx.xxx.com',
pathRewrite: { '^/api': '/api' },
secure: false,
changeOrigin: true
}
}),
复制代码

此外不要忘记更改 axios 的配置中的baseURL,将其改为/api/


链接:https://juejin.cn/post/6987408221669425189

收起阅读 »

「自我检验」输入URL发生了啥?希望你顺便懂这15个知识点

输入URL发生了啥? 1、浏览器的地址栏输入URL并按下回车。 2、浏览器查找当前URL是否存在缓存,并比较缓存是否过期。 3、DNS解析URL对应的IP。 4、根据IP建立TCP连接(三次握手)。 5、HTTP发起请求。 6、服务器处理请求,浏览器接收HT...
继续阅读 »

输入URL发生了啥?



  • 1、浏览器的地址栏输入URL并按下回车。

  • 2、浏览器查找当前URL是否存在缓存,并比较缓存是否过期。

  • 3、DNS解析URL对应的IP。

  • 4、根据IP建立TCP连接(三次握手)。

  • 5、HTTP发起请求。

  • 6、服务器处理请求,浏览器接收HTTP响应。

  • 7、渲染页面,构建DOM树。

  • 8、关闭TCP连接(四次挥手)。


永恒钻石


1. 浏览器应该具备什么功能?



  • 1、网络:浏览器通过网络模块来下载各式各样的资源,例如HTML文本,JavaScript代码,CSS样式表,图片,音视频文件等。网络部分尤为重要,因为它耗时长,而且需要安全访问互联网上的资源

  • 2、资源管理:从网络下载,或者本地获取到的资源需要有高效的机制来管理他们。例如如何避免重复下载,资源如何缓存等等

  • 3、网页浏览:这是浏览器的核心也是最基本的功能,最重要的功能。这个功能决定了如何将资源转变为可视化的结果

  • 4、多页面管理

  • 5、插件与管理

  • 6、账户和同步

  • 7、安全机制

  • 8、开发者工具


浏览器的主要功能总结起来就是一句话:将用户输入的url转变成可视化的图像


2. 浏览器的内核


在浏览器中有一个最重要的模块,它主要的作用是把一切请求回来的资源变成可视化的图像,这个模块就是浏览器内核,通常他也被称为渲染引擎


下面是浏览器内核的总结:



  • 1、IE:Trident

  • 2、Safari:WebKit。WebKit本身主要是由两个小引擎构成的,一个正是渲染引擎“WebCore”,另一个则是javascript解释引擎“JSCore”,它们均是从KDE的渲染引擎KHTML及javascript解释引擎KJS衍生而来。

  • 3、Chrome:Blink。在13年发布的Chrome 28.0.1469.0版本开始,Chrome放弃Chromium引擎转而使用最新的Blink引擎(基于WebKit2——苹果公司于2010年推出的新的WebKit引擎),Blink对比上一代的引擎精简了代码、改善了DOM框架,也提升了安全性。

  • 4、Opera:2013年2月宣布放弃Presto,采用Chromium引擎,又转为Blink引擎

  • 5、Firefox:Gecko


3. 进程和线程



  • 1、进程:程序的一次执行,它占有一片独有的内存空间,是操作系统执行的基本单元

  • 2、线程:是进程内的一个独立执行单元,是CPU调度的最小单元,程序运行基本单元

  • 3、一个进程中至少有一个运行的线程:主线程。它在进程启动后自动创建

  • 4、一个进程可以同时运行多个线程,我们常说程序是多线程运行的,比如你使用听歌软件,这个软件就是一个进程,而你在这个软件里听歌收藏歌点赞评论,这就是一个进程里的多个线程操作

  • 5、一个进程中的数据可以供其中的多个线程直接共享,但是进程与进程之间的数据时不能共享

  • 6、JS引擎是单线程运行


4. 浏览器渲染引擎的主要模块



  • 1、HTML解析器:解释HTML文档的解析器,主要作用是将HTML文本解释为DOM树

  • 2、CSS解析器:它的作用是为DOM中的各个元素对象计算出样式信息,为布局提供基础设施

  • 3、JavaScript引擎:JavaScript引擎能够解释JavaScript代码,并通过DOM接口和CSS接口来修改网页内容 和样式信息,从而改变渲染的结果

  • 4、布局(layout):在DOM创建之后,WebKit需要将其中的元素对象同样式信息结合起来,计算他们的大小位置等布局信息,形成一个能表达着所有信息的内部表示模型

  • 5、绘图模块(paint):使用图形库将布局计算后的各个网页的节点绘制成图像结果


5. 大致的渲染过程


第1题的第7点,渲染页面,构建DOM树,接下来说说大致的渲染过程



  • 1、浏览器会从上到下解析文档

  • 2、遇见HTML标记,调用HTML解析器解析为对应的token(一个token就是一个标签文本的序列化)并构建DOM树(就是一块内存,保存着tokens,建立他们之间的关系)

  • 3、遇见style/link标记调用相应解析器处理CSS标记,并构建出CSS样式树

  • 4、遇见script标记,调用JavaScript引擎处理script标记,绑定事件,修改DOM树/CSS树等

  • 5、将DOM树与CSS合并成一个渲染树

  • 6、根据渲染树来渲染,以计算每个节点的几何信息(这一过程需要依赖GPU)

  • 7、最终将各个节点绘制在屏幕上


02_浏览器渲染过程的副本.png


至尊星耀


6. CSS阻塞情况以及优化



  • 1、style标签中的样式:由HTML解析器进行解析,不会阻塞浏览器渲染(可能会产生“闪屏现象”),不会阻塞DOM解析

  • 2、link引入的CSS样式:由CSS解析器进行解析,阻塞浏览器渲染,会阻塞后面的js语句执行,不阻塞DOM的解析

  • 3、优化:使用CDN节点进行外部资源加速,对CSS进行压缩,优化CSS代码(不要使用太多层选择器)


注意:看下图,HTMLCSS是并行解析的,所以CSS不会阻塞HTML解析,但是,会阻塞整体页面的渲染(因为最后要渲染必须CSS和HTML一起解析完并合成一处)
02_浏览器渲染过程的副本.png


7. JS阻塞问题



  • 1、js会阻塞后续DOM的解析,原因是:浏览器不知道后续脚本的内容,如果先去解析了下面的DOM,而随后的js删除了后面所有的DOM,那么浏览器就做了无用功,浏览器无法预估脚本里面具体做了什么操作,例如像document.write这种操作,索性全部停住,等脚本执行完了,浏览器再继续向下解析DOM

  • 2、js会阻塞页面渲染,原因是:js中也可以给DOM设置样式,浏览器等该脚本执行完毕,渲染出一个最终结果,避免做无用功。

  • 3、js会阻塞后续js的执行,原因是维护依赖关系,例如:必须先引入jQuery再引入bootstrap


8. 资源加载阻塞


无论css阻塞,还是js阻塞,都不会阻塞浏览器加载外部资源(图片、视频、样式、脚本等)


原因:浏览器始终处于一种:“先把请求发出去”的工作模式,只要是涉及到网络请求的内容,无论是:图片、样式、脚本,都会先发送请求去获取资源,至于资源到本地之后什么时候用,由浏览器自己协调。这种做法效率很高。


9. 为什么CSS解析顺序从右到左


如果是从左到右的话:



  • 1、第一次从爷节点 -> 子节点 -> 孙节点1

  • 2、第一次从爷节点 -> 子节点 -> 孙节点2

  • 3、第一次从爷节点 -> 子节点 -> 孙节点3


如果三次都匹配不到的话,那至少也得走三次:爷节点 -> 子节点 -> 孙节点,这就做了很多无用功啊。


截屏2021-07-18 下午9.33.13.png


如果是从右到左的话:



  • 1、第一次从孙节点1,找不到,停止

  • 2、第一次从孙节点2,找不到,停止

  • 3、第一次从孙节点3,找不到,停止


这样的话,尽早发现找不到,尽早停止,可以少了很多无用功。


截屏2021-07-18 下午9.37.16.png


最强王者


10. 什么是重绘回流



  • 1、重绘:重绘是一个元素外观的改变所触发的浏览器行为,例如改变outline、背景色等属性。浏览器会根据元素的新属性重新绘制,使元素呈现新的外观。重绘不会带来重新布局,所以并不一定伴随重排。

  • 2、回流:渲染对象在创建完成并添加到渲染树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排,或回流

  • 3、"重绘"不一定需要"重排",比如改变某个网页元素的颜色,就只会触发"重绘",不会触发"重排",因为布局没有改变。

  • 4、"重排"大多数情况下会导致"重绘",比如改变一个网页元素的位置,就会同时触发"重排"和"重绘",因为布局改变了。


11. 触发重绘的属性


* color * background * outline-color * border-style * background-image * outline * border-radius * background-position * outline-style * visibility * background-repeat * outline-width * text-decoration * background-size * box-shadow


12. 触发回流的属性


* width * top * text-align * height * bottom * overflow-y * padding * left * font-weight * margin * right * overflow * display * position * font-family * border-width * float * line-height * border * clear * vertival-align * min-height * white-space


13. 常见触发重绘回流的行为



  • 1、当你增加、删除、修改 DOM 结点时,会导致 Reflow , Repaint。

  • 2、当你移动 DOM 的位置

  • 3、当你修改 CSS 样式的时候。

  • 4、当你Resize窗口的时候(移动端没有这个问题,因为移动端的缩放没有影响布局视口)

  • 5、当你修改网页的默认字体时。

  • 6、获取DOM的height或者width时,例如clientWidth、clientHeight、clientTop、clientLeft、offsetWidth、offsetHeight、offsetTop、offsetLeft、scrollWidth、scrollHeight、scrollTop、scrollLeft、scrollIntoView()、scrollIntoViewIfNeeded()、getComputedStyle()、getBoundingClientRect()、scrollTo()


14. 针对重绘回流的优化方案



  • 1、元素位置移动变换时尽量使用CSS3的transform来代替top,left等操作

  • 2、不要使用table布局

  • 3、将多次改变样式属性的操作合并成一次操作

  • 4、利用文档素碎片(documentFragment),vue使用了该方式提升性能

  • 5、动画实现过程中,启用GPU硬件加速:transform:tranlateZ(0)

  • 6、为动画元素新建图层,提高动画元素的z-index

  • 7、编写动画时,尽量使用requestAnimationFrame


15. 浏览器缓存分类


image.png



  1. 强缓存

    1. 不会向服务器发送请求,直接从本地缓存中获取数据

    2. 请求资源的的状态码为: 200 ok(from memory cache)

    3. 优先级:cache-control > expires



  2. 协商缓存

    1. 向服务器发送请求,服务器会根据请求头的资源判断是否命中协商缓存

    2. 如果命中,则返回304状态码通知浏览器从缓存中读取资源

    3. 优先级:Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,一致的情况下,才会继续比对Last-Modified,最后才决定是否返回304
链接:https://juejin.cn/post/6986416221323264030

收起阅读 »

今天聊:大厂如何用一道编程题考察候选人水平

进入正题 面试环节对面试官的一些挑战 面试官和候选人的知识结构可能有差异 => 可能会错过优秀的人 遇到「面霸」,频繁面试刷题,但是实际能力一般 => 招到不合适的人 要在短短半个小时到一个小时内判断一个人,其实很难 相对靠谱的做法 笔试:"...
继续阅读 »

进入正题


面试环节对面试官的一些挑战



  • 面试官和候选人的知识结构可能有差异 => 可能会错过优秀的人

  • 遇到「面霸」,频繁面试刷题,但是实际能力一般 => 招到不合适的人

  • 要在短短半个小时到一个小时内判断一个人,其实很难


相对靠谱的做法



  • 笔试:"Talk is cheap, show me the code"


笔试常见的问题



  • 考通用算法,Google 能直接搜到,失去考察意义

  • 题目难度设计有问题。要么满分,要么零分,可能错过还不错的同学

  • 和实际工作内容脱节


我认为好的笔试题



  • 上手门槛低,所有人多多少少都能写一点,不至于开天窗

  • 考点多,通过一道题可以基本摸清候选人的代码综合素养

  • 给高端的人有足够的发挥空间。同样的结果,不同的实现方式可以看出候选人的技术深度


我常用的一道笔试题


很普通的一道题


// 假设本地机器无法做加减乘除运算,需要通过远程请求让服务端来实现。
// 以加法为例,现有远程API的模拟实现

const addRemote = async (a, b) => new Promise(resolve => {
setTimeout(() => resolve(a + b), 1000)
});

// 请实现本地的add方法,调用addRemote,能最优的实现输入数字的加法。
async function add(...inputs) {
// 你的实现
}

// 请用示例验证运行结果:
add(1, 2)
.then(result => {
console.log(result); // 3
});


add(3, 5, 2)
.then(result => {
console.log(result); // 10
})

答案一
最基本的答案,如果写不出来,那大概率是通过不了了


async function add(...args) {
let res = 0;
if (args.length <= 2) return res;

for (const item of args) {
res = await addRemote(res, item);
}
return res;
}

递归版本


async function add(...args) {
let res = 0;
if (args.length === 0) return res;
if (args.length === 1) return args[0];

const a = args.pop();
const b = args.pop();
args.push(await addRemote(a, b));
return add(...args);
}

常见的问题:



  • 没有判断入参个数

  • 仍然用了本地加法


答案二
有候选人的答案如下:


// Promise链式调用版本
async function add(...args) {
return args.reduce((promiseChain, item) => {
return promiseChain.then(res => {
return addRemote(res, item);
});
}, Promise.resolve(0));

}

从这个实现可以看出:



  • 对 Array.prototype.reduce 的掌握

  • 对于 Promise 链式调用的理解

  • 考察候选人对 async function 本质的理解


这个版本至少能到 70 分


答案三
之前的答案结果都是对的,但是我们把耗时打出来,可以看到耗时和参数个数成线性关系,因为所有计算都是串行的,显然不是最优的解



更好一点的答案:


function add(...args) {
if (args.length <= 1) return Promise.resolve(args[0])
const promiseList = []
for (let i = 0; i * 2 < args.length - 1; i++) {
const promise = addRemote(args[i * 2], args[i * 2 + 1])
promiseList.push(promise)
}

if (args.length % 2) {
const promise = Promise.resolve(args[args.length - 1])
promiseList.push(promise)
}

return Promise.all(promiseList).then(results => add(...results));
}


可以看到很明显的优化。


答案四
还能再优化吗?
有些同学会想到加本地缓存


const cache = {};

function addFn(a, b) {
const key1 = `${a}${b}`;
const key2 = `${b}${a}`;
const cacheVal = cache[key1] || cache[key2];

if (cacheVal) return Promise.resolve(cacheVal);

return addRemote(a, b, res => {
cache[key1] = res;
cache[key2] = res;
return res;
});
}

加了缓存以后,我们再第二次执行相同参数加法时,可以不用请求远端,直接变成毫秒级返回



还能再优化吗?交给大家去思考


其他考察点


有些时候会让候选人将代码提交到 Github 仓库,以工作中一个实际的模块标准来开发,可以考察:



  • git 操作,commit 规范

  • 工程化素养

  • 是否有单元测试

  • 覆盖率是否达标

  • 依赖的模块版本如何设置

  • 如何配置 ci/cd

  • 文档、注释

  • ...


更加开放的一种笔试形式



  • 给一道题目,让候选人建一个 Github 仓库来完成

  • 题目有一定难度,但是可以 Google,也可以用三方模块,和我们平时做项目差不多

  • 通常面向级别较高的候选人


实际题目


// 有一个 10G 文件,每一行是一个时间戳,
// 现在要在一台 2C4G 的机器上对它进行排序,输出排序以后的文件

// 案例输入
// 1570593273487
// 1570593273486
// 1570593273488
// …

// 输出
// 1570593273486
// 1570593273487
// 1570593273488
// …



先看一个答案,看看哪里有问题


async function sort(inputFile, outputFile) {
const input = fs.createReadStream(inputFile);
const rl = readline.createInterface({ input });
const arr = [];
for await (const line of rl) {
const item = Number(line);
arr.push(item);
}
arr.sort((a, b) => a - b);

fs.writeFileSync(outputFile, arr.join('\n'));
}

10GB 的文件无法一次性放进内存里处理,内存只有 4GB


再看一个神奇的答案,只有一行代码,而且从结果来说是正确的。但不是我们笔试想要的答案。


const cp = require('child_process');

function sort(inputFile, outputFile) {
cp.exec(`sort -n ${inputFile} > ${outputFile}`);
}

解题思路



  • 既然没办法一次性在内存中排序,那我们能否将 10GB 的文件拆分成若干个小文件

  • 小文件先分别排序,然后再合并成一个大的文件


再将问题拆解



  • 拆分大文件到小文件

  • 小文件在内存里排序

  • 合并所有小文件成一个整体排序过的大文件


本题最难的点在于如何合并所有小文件。代码如何实现?



  • 这里需要用到一种数据结构:堆

  • 堆:就是用数组实现的一个二叉树

  • 堆分为:最大堆和最小堆,下面是一个最小堆(父节点小于它的子节点)


image.png


堆有一些特性:



  • 对于一个父节点来说

    • 左节点位置:父节点位置 * 2 + 1

    • 右节点位置:父节点位置 * 2 + 2



  • 很容易查找最大值 / 最小值


我们尝试把下面数组构造成一个最小堆


image.png



  • 从最后一个非叶子节点开始往前处理

  • 10 比 5 大,所以交换它们的位置


image.png



  • 然后是节点 2,符合要求不需要处理

  • 最后到顶点 3,它比左子节点大,所以要交换


image.png


完整的实现参考:github.com/gxcsoccer/e…
image.png







链接:https://juejin.cn/post/6987529814324281380

收起阅读 »

基础篇 - 从构建层面看 import 和 require 的区别

前言 一切的一切,都是因为群里的一个问题 虽然说最近在做 webpack 相关的事情,但是也没有对着干问题做过相关的研究,网上很多文章包括 vue 都介绍了建议使用 import ,但是没有说为什么要使用 import,对于开发者来说,调用的方式是没有区别的...
继续阅读 »

前言


一切的一切,都是因为群里的一个问题


image.png


虽然说最近在做 webpack 相关的事情,但是也没有对着干问题做过相关的研究,网上很多文章包括 vue 都介绍了建议使用 import ,但是没有说为什么要使用 import,对于开发者来说,调用的方式是没有区别的,那么为什么 import 的包就要比 require 的包小呢


这里暂时就不说什么调用方式了,什么动态加载(require)、静态编译(import)的,这个网上都有,这篇文章就是分析一下为什么要用 import,而不用 require


正文


首先本地先基于 webpack 搭建一个环境只是为了测试,不需要搭建太复杂的内容


基础文件内容


// webpack.config.js
module.exports = {
mode: 'development',
entry: './src/index.js'
}

index.js 内添加两种调用方式


function test() {
const { b } = import('./importtest')
console.log(b())
}
test()

// or

function test() {
const { b } = require('./requiretest')
console.log(b())
}
test()

importtest.js 中也是简单输出一下


// importtest.js
export default {
b: function () {
return {
name: 'zhangsan'
}
}
}

requiretest.js 也是如此


// requiretest.js
module.exports = {
b: function() {
return {
name: 'lisi'
}
}
}

上述的方式分别执行 webpack 后,输出的内容分别如下


import 输出


image.png


在打包时一共输出了两个文件:main.jssrc_importtest_js.jsmain.js 里面输出的内容如下


image.png


main.js 里面就是 index.js 里面的内容,importtest 里面的内容,是通过一个索引的方式引用过来的,引用的地址就是 src_importtest_js.js


require 输出


image.png


require 打包时,直接输出了一个文件,就只有一个 main.jsmain.js 里面输出的内容如下


image.png


main.js 里面的内容是 index.jsrequiretest.js 里面的所有内容


综上所述,我们从数据角度来看 import 的包是要大于 require 的,但通过打包文件来看,由业务代码导致的文件大小其实 import 是要小于 require 的
复制代码

多引用情况下导致的打包变化


这个时候我们大概知道了 importrequire 打包的区别,接下来我们可以模拟一下一开始那位同学的问题,直接修改一下 webpack.config.js 的入口即可


module.exports = {
mode: 'development',
entry: {
index: './src/index.js',
index1: './src/index1.js'
}
}
复制代码

这里直接保证 index.jsindex1.js 的内容一样即可,还是先测试一下 import 的打包


image.png


这里的内容和单入口时打包的 import 基本一致,里面出了本身的内容外,都是引用的 src_importtest_js 的地址,那么在看看 require 的包


image.png


这里内容和单入口打包的 require 基本一致,都是把 requiretest 的内容复制到了对应的文件内


虽然我们现在看的感觉多入口打包,还是 import 的文件要比 require 的文件大,但是核心问题在于测试案例的业务代码量比较少,所以看起来感觉 import 要比 require 大,当我们的业务代码量达到实际标准的时候,区别就看出来了


总结


import: 打包的内容是给到一个路径,通过该路径来访问对应的内容


require: 把当前访问资源的内容,打包到当前的文件内


到这里就可以解释为什么 vue 官方和网上的文章说推荐 import 而不推荐 require,因为每一个使用 require 的文件会把当前 require 的内容打包到当前文件内,所以导致了文件的过大,使用 import,抛出来的是一个索引,所以不会导致重复内容的打包,就不会出现包大的情况


当然这也不是绝对的,就好像上述案例那种少量的业务代码,使用 import 的代码量其实要比 require 大,所以不建议大家直接去确定某一种方式是最好的,某一种方式就是不行的,依场景选择方法


尾声


这篇文章就是一个简单的平时技术方面基础研究的简介,不是特别高深的东西,还希望对大家有所帮助,如果有覆盖面不够,或者场景不全面的情况,还希望大家提出,我在继续补充


这种类型的文章不是我擅长的方向,还是喜欢研究一些新的东西,欢迎大家指教:


链接:https://juejin.cn/post/6987219589612601357

收起阅读 »

小程序页面返回传值四种解决方案总结

使用场景 小程序从A页面跳转到B页面,在B页面选择一个值后返回到A页面,在A页面使用在B页面选中的值。例如:在购买订单页面跳转到地址列表,选择完地址以后回退到订单页面,订单页面的配送地址需要同步更新。 解决方案 常见的比容要容易解决的方案是使用小程序的全局存储...
继续阅读 »

使用场景


小程序从A页面跳转到B页面,在B页面选择一个值后返回到A页面,在A页面使用在B页面选中的值。例如:在购买订单页面跳转到地址列表,选择完地址以后回退到订单页面,订单页面的配送地址需要同步更新。


解决方案


常见的比容要容易解决的方案是使用小程序的全局存储globalData、本地缓存storage、获取小程序的页面栈,调用上一个Page的setData方法、以及利用wx.navigateTo的events属性监听被打开页面发送到当前页面的数据。下面给大家简单对比下四种方法的优缺点:


1、使用globalData实现


//page A
const app = getApp() //获取App.js实例
onShow() {//生命周期函数--监听页面显示
if (app.globalData.backData) {
this.setData({ //将B页面更新完的值渲染到页面上
backData: app.globalData.backData
},()=>{
delete app.globalData.backData //删除数据 避免onShow重复渲染
})
}
}
//page B
const app = getApp() //获取App.js实例
changeBackData(){
app.globalData.backData = '我被修改了'
wx.navigateBack()
}

2、使用本地缓存Storage实现


//page A
onShow: function () {
let backData = wx.getStorageSync('backData')
if(backData){
this.setData({
backData
},()=>{
wx.removeStorageSync('backData')
})
}
},
//page B
changeBackData(){
wx.setStorageSync('backData', '我被修改了')
wx.navigateBack()
},

3、使用小程序的Page页面栈实现


使小程序的页面栈,比其他两种方式会更方便一点而且渲染的会更快一些,不需要等回退到A页面上再把数据渲染出来,在B页面上的直接就会更新A页面上的值,回退到A页面的时候,值已经被更新了。globalData和Storage实现的原理都是在B页面上修改完值以后,回退到A页面,触发onShow生命周期函数,来更新页面渲染。


//page B
changeBackData(){
const pages = getCurrentPages();
const beforePage = pages[pages.length - 2]
beforePage.setData({ //会直接更新A页面的数据,A页面不需要其他操作
backData: "我被修改了"
})

4、使用wx.navigateTo API的events实现


wx.navigateTo的events的实现原理是利用设计模式的发布订阅模式实现的,有兴趣的同学可以自己动手实现一个简单的,也可以实现相同的效果。


//page A
goPageB() {
wx.navigateTo({
url: 'B',
events: {
getBackData: res => { //在events里面添加监听事件
this.setData({
backData: res.backData
})
},
},
})
},
//page B
changeBackData(){
const eventChannel = this.getOpenerEventChannel()
eventChannel.emit('getBackData', {
backData: '我被修改了'
});
wx.navigateBack()
}

总结


1和2两种方法在页面渲染效果上比后面两种稍微慢一点,3和4两种方法在B页面回退到A页面之前已经触发了更新,而1和2两种方法是等返回到A页面之后,在A页面才触发更新。并且1和2两种方式,要考虑到A页面更新完以后要删除globalData和Storage的数据,避免onShow方法里面重复触发setData更新页面,所以个人更推荐大家使用后面的3和4两种方式。


链接:https://juejin.cn/post/6986556857703727117

收起阅读 »