注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

这里是一个让你为所欲为,欲罢不能的抽奖demo

寒暄 抽奖系统有很多,各式各样的,不知道大伙都抽中过什么,还是像我这样经历了绝望,看破红尘,存起来留给下一代。 这种抽奖场景在活动中很常见,为了更好的摸鱼,决定自己去写一个插件来解决重复劳动。接下来为大伙介绍一个不错的宫格抽奖组件,请看官往下挪步 关于gri...
继续阅读 »

寒暄


抽奖系统有很多,各式各样的,不知道大伙都抽中过什么,还是像我这样经历了绝望,看破红尘,存起来留给下一代。


image.png


这种抽奖场景在活动中很常见,为了更好的摸鱼,决定自己去写一个插件来解决重复劳动。接下来为大伙介绍一个不错的宫格抽奖组件,请看官往下挪步


关于grid-roll


grid-roll是一个vue的宫格组件,它让ui和逻辑分离,封装了逻辑和宫格布局,让开发者只关注奖品和按钮的ui部分。



  • 自定义宫格数量,经典的3x3还是10x100都不在话下

  • 多抽功能,一次点击多次抽奖,谷底梭哈,就问你刺不刺激


安装


npm i grid-roll -S
yarn add grid-roll

引入


/** 引入 */
import { gridRoll, gridStart, gridPrize } from 'grid-roll'
import 'grid-roll/dist/grid-roll.min.css'

实践


通过vuecli搭起新项目,这边我们可以直接用掘金抽奖的图片链接,拿过来吧你。


图片上的奖品我都打上了数字记号,这些记号其实就奖品数组的下标,它们对应着奖品位置,布局从左到右一行一行排列,所以我们的奖品数组元素排序要注意下


image.png


通过使用grid-roll,我们只需要定义里面8个奖品和1个按钮的样式就行,用gridStart和gridPrize去包装这些物料,塞进gridRoll里面,gridRoll会帮我们自动调整成九宫格布局。这里,我更喜欢把奖品写成数据去循环生成gridPrize。然后样式布局基本是打开开发者工具复制掘金的样式,所以就不细说了


image.png


介绍下这3个组件:



  • gridRoll:interval这个属性用来定义宫格之前的间隔,默认是没有间隔的,这里我看感觉定义了6px。并且接受两个插槽button和prize

  • gridStart:专门用来做button插槽的组件

  • gridPrize:专门用来做prize插槽的组件









// 这里引入组件和样式
import { gridRoll, gridStart, gridPrize } from "grid-roll";
import "grid-roll/dist/grid-roll.min.css";
expoet default {
data () {
return {
prizes: [
{
id: 1,
text: "66矿石",
img: "https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/32ed6a7619934144882d841761b63d3c~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 2,
text: "随机限量徽章",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/71c68de6368548bd9bd6c8888542f911~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 3,
text: "掘金新款T恤",
img: "https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5bf91038a6384fc3927dee294a38006b~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 4,
text: "Bug",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0a4ce25d48b8405cbf5444b6195928d4~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 5,
text: "再抽2次解锁",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/aabe49b0d5c741fa8d92ff94cd17cb90~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 6,
text: "掘金限量桌垫",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c78f363f41a741ffa11dcc8a92b72407~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 7,
text: "Yoyo抱枕",
img: "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/33f4d465a6a9462f9b1b19b3104c8f91~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
{
id: 8,
text: "再抽3次解锁",
img: "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4decbd721b2b48098a1ecf879cfca677~tplv-k3u1fbpfcp-no-mark:0:0:0:0.awebp",
},
],
}
}
components: {
gridRoll,
gridStart,
gridPrize,
},
}

从上面可以看到,我们只需要通过gridStart和gridPrize定义好按钮和奖品的样式,放进gridRoll就行,不用再去管其他乱七八糟的操作。


disabled的使用


从官方的图看起来,这边还缺少一个“锁”样式,需要通过抽奖次数进行解锁,除了奖品样式的不同,在滚动的时候还会直接跳过未解锁的奖品。这边gridPrize也有一个对应的prop做这件事。


首先在prizes需要用到“锁”的元素中添加一个字段disabled: true,传给gridPrize,当抽奖开始的时候,滚动会直接跳过disabled为true的奖品,其次我们用disabled来做一些样式区分,这里样式也是照抄掘金




image.png


这里我们基本就完成静态样式啦,接下来就是说说怎么触发这个抽奖


抽奖


抽奖的行为是由gridPrize的startRoll函数提供的,这里通过ref获取gridRoll的实例,定义一个handleLottery方法用来触发startRoll函数。再把handleLottery绑定的抽奖按钮上







methods: {
async handleLottery() {
const value = 1;
/**
* 这里的value为1是指抽取id为1的奖品
* 返回一个Promise实例,内部为了防止多次触发抽奖逻辑,
* resolve会传递一个Boolean,进行是false,抽奖结束返回true
*/

const b = await this.$refs.dial.startRoll(value);
if (b) {
alert(
`🎉你抽到${this.prizes.find((prize) => prize.id === value).text}`
);
} else {
console.warn("稍安勿躁");
}
},
},

同时别忘记了,抽奖滚动的时候,有一个选中的样式,这里gridPrize作用域插槽提供了一个isSelect值用来判断是否滚动到当前奖品,用来做一些样式切换





收起阅读 »

vue、react函数式编程

函数式编程 JavaScript 语言从一诞生,就具有函数式编程的烙印。它将函数作为一种独立的数据类型,与其他数据类型处于完全平等的地位。在 JavaScript 语言中,你可以采用面向对象编程,也可以采用函数式编程。有人甚至说,JavaScript 是有史以...
继续阅读 »

函数式编程


JavaScript 语言从一诞生,就具有函数式编程的烙印。它将函数作为一种独立的数据类型,与其他数据类型处于完全平等的地位。在 JavaScript 语言中,你可以采用面向对象编程,也可以采用函数式编程。有人甚至说,JavaScript 是有史以来第一种被大规模采用的函数式编程语言。


ES6 的种种新增功能,使得函数式编程变得更方便、更强大。本章介绍 ES6 如何进行函数式编程。


柯里化


柯里化(currying)指的是将一个多参数的函数拆分成一系列函数,每个拆分后的函数都只接受一个参数(unary)。


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

add(1, 1) // 2

上面代码中,函数add接受两个参数ab


柯里化就是将上面的函数拆分成两个函数,每个函数都只接受一个参数。


function add (a) {
return function (b) {
return a + b;
}
}
// 或者采用箭头函数写法
const add = x => y => x + y;

const f = add(1);
f(1) // 2

上面代码中,函数add只接受一个参数a,返回一个函数f。函数f也只接受一个参数b


函数合成


函数合成(function composition)指的是,将多个函数合成一个函数。


const compose = f => g => x => f(g(x));

const f = compose (x => x * 4) (x => x + 3);
f(2) // 20

上面代码中,compose就是一个函数合成器,用于将两个函数合成一个函数。


可以发现,柯里化与函数合成有着密切的联系。前者用于将一个函数拆成多个函数,后者用于将多个函数合并成一个函数。


参数倒置


参数倒置(flip)指的是改变函数前两个参数的顺序。


var divide = (a, b) => a / b;
var flip = f.flip(divide);

flip(10, 5) // 0.5
flip(1, 10) // 10

var three = (a, b, c) => [a, b, c];
var flip = f.flip(three);
flip(1, 2, 3); // => [2, 1, 3]

上面代码中,如果按照正常的参数顺序,10 除以 5 等于 2。但是,参数倒置以后得到的新函数,结果就是 5 除以 10,结果得到 0.5。如果原函数有 3 个参数,则只颠倒前两个参数的位置。


参数倒置的代码非常简单。


let f = {};
f.flip =
fn =>
(a, b, ...args) => fn(b, a, ...args.reverse());

执行边界


执行边界(until)指的是函数执行到满足条件为止。


let condition = x => x > 100;
let inc = x => x + 1;
let until = f.until(condition, inc);

until(0) // 101

condition = x => x === 5;
until = f.until(condition, inc);

until(3) // 5

上面代码中,第一段的条件是执行到x大于 100 为止,所以x初值为 0 时,会一直执行到 101。第二段的条件是执行到等于 5 为止,所以x最后的值是 5。


执行边界的实现如下。


let f = {};
f.until = (condition, f) =>
(...args) => {
var r = f.apply(null, args);
return condition(r) ? r : f.until(condition, f)(r);
};

上面代码的关键就是,如果满足条件就返回结果,否则不断递归执行。


队列操作


队列(list)操作包括以下几种。



  • head: 取出队列的第一个非空成员。

  • last: 取出有限队列的最后一个非空成员。

  • tail: 取出除了“队列头”以外的其他非空成员。

  • init: 取出除了“队列尾”以外的其他非空成员。


下面是例子。


f.head(5, 27, 3, 1) // 5
f.last(5, 27, 3, 1) // 1
f.tail(5, 27, 3, 1) // [27, 3, 1]
f.init(5, 27, 3, 1) // [5, 27, 3]

这些方法的实现如下。


let f = {};
f.head = (...xs) => xs[0];
f.last = (...xs) => xs.slice(-1);
f.tail = (...xs) => Array.prototype.slice.call(xs, 1);
f.init = (...xs) => xs.slice(0, -1);

合并操作


合并操作分为concatconcatMap两种。前者就是将多个数组合成一个,后者则是先处理一下参数,然后再将处理结果合成一个数组。


f.concat([5], [27], [3]) // [5, 27, 3]
f.concatMap(x => 'hi ' + x, 1, [[2]], 3) // ['hi 1', 'hi 2', 'hi 3']

这两种方法的实现代码如下。


let f = {};
f.concat =
(...xs) => xs.reduce((a, b) => a.concat(b));
f.concatMap =
(f, ...xs) => f.concat(xs.map(f));

配对操作


配对操作分为zipzipWith两种方法。zip操作将两个队列的成员,一一配对,合成一个新的队列。如果两个队列不等长,较长的那个队列多出来的成员,会被忽略。zipWith操作的第一个参数是一个函数,然后会将后面的队列成员一一配对,输入该函数,返回值就组成一个新的队列。


下面是例子。


let a = [0, 1, 2];
let b = [3, 4, 5];
let c = [6, 7, 8];

f.zip(a, b) // [[0, 3], [1, 4], [2, 5]]
f.zipWith((a, b) => a + b, a, b, c) // [9, 12, 15]

上面代码中,zipWith方法的第一个参数是一个求和函数,它将后面三个队列的成员,一一配对进行相加。


这两个方法的实现如下。


let f = {};

f.zip = (...xs) => {
let r = [];
let nple = [];
let length = Math.min.apply(null, xs.map(x => x.length));

for (var i = 0; i < length; i++) {
xs.forEach(
x => nple.push(x[i])
);

r.push(nple);
nple = [];
}

return r;
};

f.zipWith = (op, ...xs) =>
f.zip.apply(null, xs).map(
(x) => x.reduce(op)
);


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

收起阅读 »

深入理解 Class 和 extends 原理

准备工作 在开始之前,我们需要一个 babel 的环境,方便查看 babel 后的代码,这里我推荐两种方式。chrome 插件 —— ScratchJS,可以设置 babel 来转换代码,通过点击 Toggle output 就能看到 babel 后的代码。b...
继续阅读 »

准备工作


在开始之前,我们需要一个 babel 的环境,方便查看 babel 后的代码,这里我推荐两种方式。

chrome 插件 —— ScratchJS,可以设置 babel 来转换代码,通过点击 Toggle output 就能看到 babel 后的代码。babel 官网推荐的在线编译工具 试一试,可以实时看到转换前后的代码。


本文将以 ScratchJS 转换后的代码为例进行代码分析。


1. class 实现


先从最简单的 class 开始看,下面这段代码涵盖了使用 class 时所有会出现的情况(静态属性、构造函数、箭头函数)。


class Person {
static instance = null;
static getInstance() {
return super.instance;
}
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHi() {
console.log('hi');
}
sayHello = () => {
console.log('hello');
}
sayBye = function() {
console.log('bye');
}
}

而经过 babel 处理后的代码是这样的:


'use strict';

var _createClass = function () {
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor)
descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
return function (Constructor, protoProps, staticProps) {
if (protoProps)
defineProperties(Constructor.prototype, protoProps);
if (staticProps)
defineProperties(Constructor, staticProps);
return Constructor;
};
}();

function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}

var Person = function () {
function Person(name, age) {
_classCallCheck(this, Person);

this.sayHello = function () {
console.log('hello');
};

this.sayBye = function () {
console.log('bye');
};

this.name = name;
this.age = age;
}

_createClass(Person, [{
key: 'sayHi',
value: function sayHi() {
console.log('hi');
}
}]);

return Person;
}();

Person.instance = null;

最外层的 Person 变量被赋值给了一个立即执行函数,立即执行函数里面返回的是里面的 Person 构造函数,实际上最外层的 Person 就是里面的 Person 构造函数。


在 Person 类上用 static 设置的静态属性instance,在这里也被直接挂载到了 Person 构造函数上。


1.1 挂载属性方法


Person 类上各个属性的关系是这样的:


image_1dmjbel2cfvdls41h2e1hcmpn39.png-30.9kB


你是不是很好奇,为什么在 Person 类上面设置的 sayHisayHellosayBye 三个方法,编译后被放到了不同的地方处理?


从编译后的代码中可以看到 sayHellosayBye 被放到了 Person 构造函数中定义,而 sayHi_createClass 来处理(_createClasssayHi 添加到了 Person 的原型上面)。


曾经我也以为是 sayHello 使用了箭头函数的缘故才让它最终被绑定到了构造函数里面,后来我看到 sayBye 这种用法才知道这和箭头函数无关。


实际上 class 中定义属性还有一种写法,这种写法和 sayBye 如出一辙,在 babel 编译后会将其属性放到构造函数中,而非原型上面。


class Person {
name = 'tom';
age = 23;
}
// 等价于
class Person {
constructor() {
this.name = 'tom';
this.age = 23;
}
}

如果我们将 name 后面的 'tom' 换成函数呢?甚至箭头函数呢?这不就是 sayByesayHello 了吗?


因此,在 class 中不直接使用 = 来定义的方法,最终都会被挂载到原型上,使用 = 定义的属性和方法,最终都会被放到构造函数中。


1.2 _classCallCheck


Person 构造函数中调用了 _classCallCheck 函数,并将 this 和自身传入进去。
_classCallCheck 中通过 instanceof 来进行判断,instance 是否在 Constructor 的原型链上面,如果不在上面则抛出错误。这一步主要是为了避免直接将 Person 类当做函数来调用。
因此,在ES5中构造函数是可以当做普通函数来调用的,但在ES6中的类是无法直接当普通函数来调用的。



注意:为什么通过 instanceof 可以判断是否将 Person 类当函数来调用呢?
因为如果使用 new 操作符实例化 Person 的时候,那么 instance 就是当前的实例,指向 Person.prototypeinstance instanceof Constructor 必然为true。反之,直接调用 Person 构造函数,那么 instance 就不会指向 Person.prototype



1.3 _createClass


我们再来看 _createClass 函数,这个函数在 Person 原型上面添加了 sayHi 方法。


// 创建原型方法
_createClass(Person, [{
key: 'sayHi',
value: function sayHi() {
console.log('hi');
}
}]);

// _createClass也是一个立即执行函数
var _createClass = function () {
// 将props属性挂载到目标target上面
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor)
descriptor.writable = true;
// 通过defineProperty来挂载属性
Object.defineProperty(target, descriptor.key, descriptor);
}
}
// 这个才是“真正的”_createClass
return function (Constructor, protoProps, staticProps) {
// 如果传入了需要挂载的原型方法
if (protoProps)
defineProperties(Constructor.prototype, protoProps);
// 如果传入了需要挂载的静态方法
if (staticProps)
defineProperties(Constructor, staticProps);
return Constructor;
};
}();

_createClass 函数接收三个参数,分别是 Constructor (构造函数)、protoProps(需要挂载到原型上的方法)、staticProps(需要挂载到类上的静态方法)。
在接收到参数之后,_createClass 会进行判断如果有 staticProps,则挂载到 Constructor 构造函数上;如果有 protoProps ,那么挂载到 Constructor 原型上面。
这里的挂载函数 defineProperties 是关键,它对传入的 props 进行了遍历,并设置了其 enumerable(是否可枚举) 和 configurable(是否可配置)、writable(是否可修改)等数据属性。
最后使用了 Object.defineProperty 函数来给设置当前对象的属性描述符。


2. extends 实现


通过上文对 Person 的分析,相信你已经知道了 ES6 中类的实现,这与ES5中的实现大同小异,接下来我们来具体看一下 extends 的实现。
以下面的 ES6 代码为例:


class Child extends Parent {
constructor(name, age) {
super(name, age);
this.name = name;
this.age = age;
}
getName() {
return this.name;
}
}

class Parent {
constructor(name, age) {
this.name = name;
this.age = age;
}
getName() {
return this.name;
}
getAge() {
return this.age;
}
}

babel后的代码则是这样的:


"use strict";

// 省略 _createClass
// 省略 _classCallCheck

function _possibleConstructorReturn(self, call) {
if (!self) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return call
&& (typeof call === "object" || typeof call === "function") ? call : self;
}

function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass)
Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

var Child = function (_Parent) {
_inherits(Child, _Parent);

function Child(name, age) {
_classCallCheck(this, Child);

var _this = _possibleConstructorReturn(this, Object.getPrototypeOf(Child).call(this, name, age));

_this.name = name;
_this.age = age;
return _this;
}

_createClass(Child, [{
key: "getName",
value: function getName() {
return this.name;
}
}]);

return Child;
}(Parent);

// 省略 Parent(类似上面的 Person 代码)

我们可以清楚地看到,继承是通过_inherits实现的。
为了方便理解,我这里整理了一下原型链的关系:


image_1dmec296p60q11bp1f8c1rid1rc52a.png-43.1kB


除去一些无关紧要的代码,最终的核心实现代码就只有这么多:


var Child = function (_Parent) {

_inherits(Child, _Parent);

function Child(name, age) {

var _this = _possibleConstructorReturn(this, Object.getPrototypeOf(Child).call(this, name, age));

_this.name = name;
_this.age = age;
return _this;
}

return Child;
}(Parent);

和前面的 Person 类实现有所不同的地方是,在 Child 方法中增加调用了 _inherits,还有在设置 nameage 属性的时候,使用的是执行 _possibleConstructorReturn 后返回的 _this,而非自身的 this,我们就重点分析这两步。


2.1 _inherits


先来看_inherits函数的实现代码:


function _inherits(subClass, superClass) { 
// 如果有一个不是函数,则抛出报错
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}
// 将 subClass.prototype 设置为 superClass.prototype 的实例
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
// 将 subClass 设置为 superClass 的实例(优先使用 Object.setPrototypeOf)
if (superClass)
Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

_inherits 函数接收两个参数,分别是 subClass (子构造函数)和 subClass (父构造函数),将这个函数做的事情稍微做一下梳理。



  1. 设置 subClass.prototype[[Prototype]]指向 superClass.prototype[[Prototype]]

  2. 设置 subClass[[Prototype]] 指向 superClass


在《深入理解类和继承》一文中,曾经提到过 ES5 中的寄生组合式继承,extends 的实现与寄生组合式继承实则大同小异,仅仅只增加了第二步操作。


2.2 _possibleConstructorReturn


Child 中调用了 _possibleConstructorReturn 函数,将 thisObject.getPrototypeOf(Child).call(this, name, age)) 传了进去。
这个 this 我们很容易理解,就是构造函数的 this,但后面这么长的一串又是什么意思呢?
刚刚在 _inherits 中设置了 Child[[Prototype]] 指向了 Parent,因此可以将后面这串代码简化为 Parent.call(this, name, age)
这样你是不是就很熟悉了?这不就是组合继承中的执行一遍父构造函数吗?
那么 Parent.call(this, name, age) 执行后返回了什么呢?
正常情况下,应该会返回 undefined,但不排除 Parent 构造函数中直接返回一个对象或者函数的可能性。
*** 小课堂:**
在构造函数中,如果什么也没有返回或者返回了原始值,那么默认会返回当前的 this;而如果返回的是引用类型,那么最终实例化后的实例依然是这个引用类型(仅相当于对这个引用类型进行了扩展)。


const obj = {};
function Parent(name) {
this.name = name;
return obj;
}
const p = new Parent('tom');
obj.name; // 'tom'
p === obj; // true

如果没有 self,这里就会直接抛出错误,提示 super 函数还没有被调用。
最后会对 call 进行判断,如果 call 为引用类型,那么返回 call,否则返回 self



注意:call 就是 Parent.call(this, name, age) 执行后返回的结果。



function _possibleConstructorReturn(self, call) { 
if (!self) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return call
&& (typeof call === "object" || typeof call === "function") ? call : self;
}

Child 方法中,最终拿到 _possibleConstructorReturn 执行后的结果作为新的 this 来设置构造函数里面的属性。



思考题:如果直接用 this,而不是 _this,会出现什么问题?



总结


ES6 中提供的 classextends 本质上只是语法糖,底层的实现原理依然是构造函数和寄生组合式继承。
所以对于一个合格的前端工程师来说,即使 ES6 已经到来,对于 ES5 中的这些基础原理我们依然需要好好掌握。


作者:sh22n
链接:https://juejin.cn/post/7001025002287923207

收起阅读 »

一个"水"按钮(滑水的水)

🐳 前言 不知道大家平时有没有留意水滴落下的瞬间。 仔细去听,仔细去看,每一滴滴水珠落下泛起的涟漪都让人意向连篇。 一个个显现而消失的涟漪就像时光仿佛带走了什么,还是留下了什么,又似乎一切都没有变,却又感觉多了些什么,让人情不自禁想要点一个赞~~ 好了不开玩...
继续阅读 »

🐳 前言



  • 不知道大家平时有没有留意水滴落下的瞬间。

  • 仔细去听,仔细去看,每一滴滴水珠落下泛起的涟漪都让人意向连篇。

  • 一个个显现而消失的涟漪就像时光仿佛带走了什么,还是留下了什么,又似乎一切都没有变,却又感觉多了些什么,让人情不自禁想要点一个~~

  • 好了不开玩笑了我们来试试做这个涟漪按钮。


water.gif


🤽‍♂️ ToDoList



  • 一片静好

  • 蜻蜓点水

  • 阵阵微波


🚿 Just Do It



  • 其实做一个这样的效果无非就是中间的按钮旁边会有两个渐渐变大的阴影,而当时间的推移,随着阴影范围变大也渐渐消失。


🌱 一片静好



  • 我们先做一个平静的湖面,也就是我们的按钮。


/** index.html **/
<div class="waterButton">
<div class="good">
<div class="good_btn" id="waterButton">
<img src="./good.png" alt="">
</div>
<span id="water1"></span>
<span id="water2"></span>
</div>
</div>


  • 在基本布局中我们需要一个div包裹住一个点赞图片来表示一个按钮,另外还需要两个span标签来表示即将泛起涟漪,这个到后面会用到。


/** button.css **/
.waterButton {
height: 27rem;
display: flex;
justify-content: center;
align-items: center;
}
.good {
width: 6rem;
height: 6rem;
position: relative;
}
.good_btn {
width: 6rem;
height: 6rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
z-index: 3;
cursor: pointer;
box-shadow: .4rem .4rem .8rem #c8d0e7,-.4rem -.4rem .8rem #fff;
}
img{
width: 50%;
height: 50%;
z-index: 4;
}


  • 因为是模拟在水中的效果所以如果按钮的阴影特别单一相同就不好了,这时候我们可以让按钮上面白色阴影下面灰色阴影,在这里推荐一个网站给大家如果需要制作这些阴影可以在这里调试 Neumorphism.io


image.png


🍃 蜻蜓点水



  • 因为是按钮我们需要一个点击事件来模拟水滴滴入湖中的感觉。

  • 而水波荡漾的感觉其实可以做成一个动画,让一个跟按钮一样的元素逐渐缩放到两倍后慢慢消失,我们可以使用两个这样的元素来在视觉上产生水波一个接一个的感觉。


.good_water-1, .good_water-2 {
width: 6rem;
height: 6rem;
border-radius: 50%;
z-index: -1;
position: absolute;
top: 0;
left: 0 ;
filter: blur(1px);
}
.good_water-1 {
box-shadow: .4rem .4rem .8rem #c8d0e7,
-.4rem -.4rem .8rem #fff;
background: linear-gradient(to bottom right, #c8d0e7 0%, #fff 100%);
animation: waves 2s linear;
}
.good_water-2 {
box-shadow: .4rem .4rem .8rem #c8d0e7,
-.4rem -.4rem .8rem #fff;
animation: waves 2s linear 1s;
}
@keyframes waves {
0% {
transform: scale(1);
opacity: 1;
}
50% {
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}


  • 跟按钮一样我们给两个水波元素也加上不同的阴影,这样的感觉会更有立体感,而为了营造水波逐渐消失的感觉,我们需要给一个过渡属性filter: blur(1px)


/** JS **/
<script>
let btn=document.getElementById('waterButton')
let water1=document.getElementById('water1')
let water2=document.getElementById('water2')
let timer=''
btn.addEventListener('click', ()=>{
window.clearTimeout(timer)
water1.classList.add("good_water-1");
water2.classList.add("good_water-2");
setTimeout(()=>{
water1.classList.remove("good_water-1");
water2.classList.remove("good_water-2");
}, 3000)
})
</script>


  • 接下来我们设定点击事件来动态添加样式并在动画结束后移除样式,这样我们来看看效果吧~


water1.gif


💦 阵阵微波



  • 如果我们不希望水波这么快停下的话,我们也可以设置水波动画为无限循环,这样的话我们就不需要点击按钮的时候再加样式了,我们之间把样式加到水波上,然后给animation设置无限循环播放infinite


.good_water-1 {
...
animation: waves 2s linear infinite;

}
.good_water-2 {
...
animation: waves 2s linear 1s infinite;
}


  • 接下来我们来看看效果吧~是不是还不错呢。


water2.gif


👋 写在最后



  • 首先感谢大家看到这里,这次分享的只是学习css中的一些乐趣,对于业务上可能不太实用,但是图个乐嘛~上班这么累,多用前端做点好玩的事情。

  • 前端世界太过奇妙,只有细心的人才能发现其乐趣,希望可以帮到有需要的人。

  • 如果您觉得这篇文章有帮助到您的的话不妨🍉关注+点赞+收藏+评论+转发🍉支持一下哟~~😛您的支持就是我更新的最大动力。

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

收起阅读 »

【前端可视化】如何在React中优雅的使用ECharts

这片文章由最近公司的一个可视化项目有感而发,随着前端的飞速发展,近年来数据可视化越来越火,有些公司的业务跟地图、位置、大数据等脱离不开关系,所以数据可视化甚至成了单独的一门前端行业,比如在杭州地区的前端可视化职位不但有一定的需求量且高薪, 至今为止,已经有很...
继续阅读 »

这片文章由最近公司的一个可视化项目有感而发,随着前端的飞速发展,近年来数据可视化越来越火,有些公司的业务跟地图、位置、大数据等脱离不开关系,所以数据可视化甚至成了单独的一门前端行业,比如在杭州地区的前端可视化职位不但有一定的需求量且高薪,


截屏2021-08-25 下午10.57.37.png


至今为止,已经有很多的可视化框架供我们选择,比如D3EChartsLeaflet....等等。


本文使用的可视化框架为ECharts


看完本文你可以学到什么?



  • 如何搭建react+ts+echarts项目

  • typescript基础使用

  • eCharts如何在react中更安全高效的使用

  • eCharts的使用

  • eCharts图表尺寸自适应

  • 启蒙可视化项目思想


本文的源码地址:github.com/Gexle-Tuy/e…


项目准备


技术栈为:React+TypeScript+ECharts。既然提到优雅那肯定跟TS逃离不开关系,毕竟强大的类型系统能给我的🐶💩代码保驾护航,什么?我不会TS,我不看了,别急,本文不做过于复杂的类型检查,只在组件状态(state)、属性(props)上做基本使用,不会TS也能看的懂,废话不多说,咱们开始吧。


使用的为react官方脚手架create-react-app,但是默认启动的为正常的js项目,如果想加上typescript类型检查,我们可以去它的仓库地址查看使用语法。在github上找到facebook/create-react-app。找到目录packages/cra-template-typescript。 在README中就可以看见启动命令create-react-app my-app --template typescript。
image


项目搭建完成之后看看src下的index文件的后缀名是否为tsx而不是jsx,为tsx就说明ts项目搭建成功了,就可以开始咱们的高雅之旅了~





初探


前面瞎吧啦半天完全跟我们本文的主角ECharts没有关系呀,伞兵作者?别急,这就开始,首先安装ECharts。


npm i echarts

安装好之后该干什么?当然是来个官方的入门例子感受一下了啦,打开官网->快速入手->绘制一个简单的图表。


可以看到,每一个图表都需要一个DOM当作容器,在React中我们可以用ref来获取到DOM实例。


image


发现平时正常写的ref竟然报错了,这就是强大的ts发挥了作用,我们把鼠标放上去可以发现提示框有一大堆东西。


不能将类型“RefObject<unknown>”分配给类型“LegacyRef<HTMLDivElement> | undefined”。
不能将类型“RefObject<unknown>”分配给类型“RefObject<HTMLDivElement>”。
不能将类型“unknown”分配给类型“HTMLDivElement”。
.....

可以根据它的提示来解决这个问题,将ref加上类型检查,本文不对ts做过多介绍,只使用简单的基础类型检查,我们直接给它加上一个:any。


eChartsRef:any= React.createRef();

这样报错就消失了,可以理解为any类型就是没有类型检查,跟普通的js一样没有区别。真正的重点不在这里,所以就直接使用any,其实应该按照它的提示加上真正的类型检查RefObject<HTMLDivElement>





拿到实例之后,直接copy官方的配置项例子过来看看效果。


import React, { PureComponent } from "react";
import * as eCharts from "echarts";

export default class App extends PureComponent {

eChartsRef: any = React.createRef();

componentDidMount() {
const myChart = eCharts.init(this.eChartsRef.current);

let option = {
title: {
text: "ECharts 入门示例",
},
tooltip: {},
legend: {
data: ["销量"],
},
xAxis: {
data: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"],
},
yAxis: {},
series: [
{
name: "销量",
type: "bar",
data: [5, 20, 36, 10, 10, 20],
},
],
};

myChart.setOption(option);
}

render() {
return <div ref={this.eChartsRef} style={{
width: 600,
height: 400,
margin: 100
}}></div>;
}
}

gif


当图标的动态效果呈现在你眼前的时候是不是心动了,原来可视化这么简单,到这里你就会了最基本的使用了。





接下来就开始本文的重点!如何在react里封装图表组件动态渲染并自适应移动端


正文


首先确定项目中我们要用到的图表,这里我选了四个最基本且常用的图表(折线图趋势图饼状图柱状图)。


所有的图表都由无状态组件写(函数组件、Hooks),因为它们只负责拿到数据并渲染。并无自己维护的状态。


接下来就是封装图表组件,这里就不把四个表的代码都贴出来了,只拿一个折线图举例子。可以把拉下源码看下其他的图。


折线图:src/components/LineChart


import React, { useEffect, useRef } from 'react';
import { IProps } from "./type";
import * as echarts from "echarts";

const Index: React.FC<IProps> = (props) => {

const chartRef:any = useRef(); //拿到DOM容器

// 每当props改变的时候就会实时重新渲染
useEffect(()=>{
const chart = echarts.init(chartRef.current); //echart初始化容器
let option = { //配置项(数据都来自于props)
title: {
text: props.title ? props.title : "暂无数据",
},
xAxis: {
type: 'category',
data: props.xData,
},
yAxis: {
type: 'value'
},
series: [{
data: props.seriesData,
type: 'line'
}]
};

chart.setOption(option);
}, [props]);

return <div ref={chartRef} className="chart"></div>
}

export default Index;

同文件下新建一个type.ts,将要约束的props类型检查单独抽离出去,当然也可以直接写在index.tsx文件里面,看个人喜好。
type.ts


// 给props添加类型检查
export interface IProps {
title: string, //图表的标题(为string类型)
xData: string[], //图表x轴数据的数组(数字里面每一项都为string类型)
seriesData: number[], //跟x轴每个坐标点对应的数据(数字里面每一项都为number类型)
}

根据每张图表对应的配置项,选出你想要动态配置的属性,就可以写成props作为属性传递过来。(比如,一个项目里需要用到很多张折线图,但是每个图表的线条颜色是不一样的,就可以把color写成一个props作为属性值传递进来。)





封装好之后,我们在App.tsx中引入使用一下。


App.tsx


import React, { PureComponent } from "react";
import LineChart from "./components/LineChart/Index";
import "./App.css";
export default class App extends PureComponent {
eChartsRef: any = React.createRef();

state = {
lineChartData: {
//折线图模拟数据
xData: [
"2021/08/13",
"2021/08/14",
"2021/08/15",
"2021/08/16",
"2021/08/17",
"2021/08/18",
],
seriesData: [22, 19, 88, 66, 5, 90],
},
};

componentDidMount() {}

render() {
return (
<div className="homeWrapper">
{/* 折线图 */}
<div className="chartWrapper">
<LineChart
title="折线图模拟数据"
xData={this.state.lineChartData.xData}
seriesData={this.state.lineChartData.seriesData}
/>
</div>
</div>
);
}
}

如果使用LineChart组件的时候少传了任何一个属性,或者说属性传递的类型不对,那么就会直接报错,将报错扼杀在开发阶段,而不是运行代码阶段,而且还有一个好处就是,加上类型检查后会有强大的智能提示,普通的js项目写一个组件根本就不会提示你需要传递某些属性。


忘记传递某个属性
image


传递的类型不符合类型检查
image


效果如下:


gif


这样一个基本的图表组件就完成了,但是都是我们模拟的数据,在真实的开发中数据都是来自于后端返回给我们,而且格式还不是我们想要的,那时候就需要我们自己处理下数据包装成需要的数据格式再传递。


这样封装成函数组件还有一个好处就是每当props改变的时候就会进行重新渲染。比如我在componentDidMount中开启一个定时器定时添加数据来模拟实时数据。


componentDidMount() {
setInterval(() => {
this.setState({
lineChartData: {
xData: [...this.state.lineChartData.xData, "2000/01/01"],
seriesData: [...this.state.lineChartData.seriesData, Math.floor(Math.random() * 100)],
}
})
}, 1500 );
}

gif


这样就可以实现展示实时数据了,比如每秒的pv、uv数等等。我们把四个图表组件全部封装好之后的效果是这样的。


gif


前三个图表的数据都来自实时数据模拟,最后一张饼状图直接在组件中写死数据了,有兴趣的小伙伴可以拉下源码自行把它实现成实时的,可以看option中的配置哪些需要配置的,单独抽离出来写在type.ts文件中。





移动端适配


啥?echarts没做移动端适配?当然不是,echarts的官网中就介绍了移动端的相关优化:echarts.apache.org/zh/feature.… 当然也有跨平台使用。


gif


好像是那么回事,但感觉好像少了些什么,好像没有根据屏幕尺寸大小变化而自动发生调整尺寸。每次都要刷新一下也就是重新进入页面。


别着急,在它的API文档中,有这么一个方法,echarts创建的实例也就是通过echarts.init()之后的对象会有一个resize的方法。


我们可以监听窗口的变化,只要窗口尺寸变化了就调用resize方法。监听窗口的变化的方法很简单window.onresize可以在创建组件对象的时候都添加上一个window.onresize方法。





注意:如果网页只有一个图表那么这么写是可以的,如果项目中图表不只一个的话,每个图表组件难道在后面都写一个window.onresize方法吗?这样写的话只有最后创建的组件会自适应屏幕尺寸大小,因为每创建一个组件都重新将window.onresize赋予为新的函数体了。





解决:我们可以写一个公用方法,每一次创建组件的时候都加入到一个数组中,当屏幕尺寸变化的时候,都去循环遍历这个数组中的每一项,然后调用resize方法。


src/util.js


const echartsDom = [];  //所有echarts图表的数组
/**
* 当屏幕尺寸变化时,循环数组里的每一项调用resize方法来实现自适应。
* @param {*} eDom
*/
export function echartsResize(eDom) {
echartsDom.push(eDom);
window.onresize = () => {
echartsDom.forEach((it)=>{
it.resize();
})
};
}

写好方法之后,在每个图表组件设置好option之后将他添加到此数组内,然后当屏幕尺寸变化后就可以将每个图表变成自适应的了。





这样之后每个图表就都可以自适应屏幕尺寸啦~


gif


结语


本文主要介绍了如何在react中更安全高效的使用eCharts,所涉及的ts都为最基础的类型检查(有兴趣的同学可以自行拓展),只是为了给各位提供一个我在写一个eCharts项目的时候如何去做和管理项目,文章有错误的地方欢迎指出,大佬勿喷,大家伙儿有更好的思路和想法欢迎大家积极留言。感谢观看~




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

收起阅读 »

DIff算法看不懂就一起来砍我(带图)

前言 面试官:"你了解虚拟DOM(Virtual DOM)跟Diff算法吗,请描述一下它们"; 我:"额,...鹅,那个",完了😰,突然智商不在线,没组织好语言没答好或者压根就答不出来; 所以这次我总结一下相关的知识点,让你可以有一个清晰的认知之余也会让你在今...
继续阅读 »

前言


面试官:"你了解虚拟DOM(Virtual DOM)Diff算法吗,请描述一下它们";


我:"额,...鹅,那个",完了😰,突然智商不在线,没组织好语言没答好或者压根就答不出来;


所以这次我总结一下相关的知识点,让你可以有一个清晰的认知之余也会让你在今后遇到这种情况可以坦然自若,应付自如,游刃有余:




相关知识点:



  • 虚拟DOM(Virtual DOM):


    • 什么是虚拟dom




    • 为什么要使用虚拟dom




    • 虚拟DOM库





  • DIFF算法:

    • snabbDom源码

      • init函数

      • h函数

      • patch函数

      • patchVnode函数

      • updateChildren函数








虚拟DOM(Virtual DOM)


什么是虚拟DOM


一句话总结虚拟DOM就是一个用来描述真实DOM的javaScript对象,这样说可能不够形象,那我们来举个🌰:分别用代码来描述真实DOM以及虚拟DOM


真实DOM:


<ul class="list">
<li>a</li>
<li>b</li>
<li>c</li>
</ul>

对应的虚拟DOM:



let vnode = h('ul.list', [
h('li','a'),
h('li','b'),
h('li','c'),
])

console.log(vnode)

控制台打印出来的Vnode:


image.png


h函数生成的虚拟DOM这个JS对象(Vnode)的源码:


export interface VNodeData {
props?: Props
attrs?: Attrs
class?: Classes
style?: VNodeStyle
dataset?: Dataset
on?: On
hero?: Hero
attachData?: AttachData
hook?: Hooks
key?: Key
ns?: string // for SVGs
fn?: () => VNode // for thunks
args?: any[] // for thunks
[key: string]: any // for any other 3rd party module
}

export type Key = string | number

const interface VNode = {
sel: string | undefined, // 选择器
data: VNodeData | undefined, // VNodeData上面定义的VNodeData
children: Array<VNode | string> | undefined, //子节点,与text互斥
text: string | undefined, // 标签中间的文本内容
elm: Node | undefined, // 转换而成的真实DOM
key: Key | undefined // 字符串或者数字
}


补充:

上面的h函数大家可能有点熟悉的感觉但是一时间也没想起来,没关系我来帮大伙回忆;
开发中常见的现实场景,render函数渲染:


// 案例1 vue项目中的main.js的创建vue实例
new Vue({
router,
store,
render: h => h(App)
}).$mount("#app");

//案例2 列表中使用render渲染
columns: [
{
title: "操作",
key: "action",
width: 150,
render: (h, params) => {
return h('div', [
h('Button', {
props: {
size: 'small'
},
style: {
marginRight: '5px',
marginBottom: '5px',
},
on: {
click: () => {
this.toEdit(params.row.uuid);
}
}
}, '编辑')
]);
}
}
]



为什么要使用虚拟DOM



  • MVVM框架解决视图和状态同步问题

  • 模板引擎可以简化视图操作,没办法跟踪状态

  • 虚拟DOM跟踪状态变化

  • 参考github上virtual-dom的动机描述

    • 虚拟DOM可以维护程序的状态,跟踪上一次的状态

    • 通过比较前后两次状态差异更新真实DOM



  • 跨平台使用

    • 浏览器平台渲染DOM

    • 服务端渲染SSR(Nuxt.js/Next.js),前端是vue向,后者是react向

    • 原生应用(Weex/React Native)

    • 小程序(mpvue/uni-app)等



  • 真实DOM的属性很多,创建DOM节点开销很大

  • 虚拟DOM只是普通JavaScript对象,描述属性并不需要很多,创建开销很小

  • 复杂视图情况下提升渲染性能(操作dom性能消耗大,减少操作dom的范围可以提升性能)


灵魂发问:使用了虚拟DOM就一定会比直接渲染真实DOM快吗?答案当然是否定的,且听我说:
2c3559e204c5aae6a1c6bfdc8557efcd.jpeg


举例:当一个节点变更时DOMA->DOMB


image.png
上述情况:
示例1是创建一个DOMB然后替换掉DOMA;
示例2创建虚拟DOM+DIFF算法比对发现DOMBDOMA不是相同的节点,最后还是创建一个DOMB然后替换掉DOMA;
可以明显看出1是更快的,同样的结果,2还要去创建虚拟DOM+DIFF算啊对比
所以说使用虚拟DOM比直接操作真实DOM就一定要快这个说法是错误的,不严谨的


举例:当DOM树里面的某个子节点的内容变更时:


image.png
当一些复杂的节点,比如说一个父节点里面有多个子节点,当只是一个子节点的内容发生了改变,那么我们没有必要像示例1重新去渲染这个DOM树,这个时候虚拟DOM+DIFF算法就能够得到很好的体现,我们通过示例2使用虚拟DOM+Diff算法去找出改变了的子节点更新它的内容就可以了


总结:复杂视图情况下提升渲染性能,因为虚拟DOM+Diff算法可以精准找到DOM树变更的地方,减少DOM的操作(重排重绘)




虚拟dom库



  • Snabbdom

    • Vue.js2.x内部使用的虚拟DOM就是改造的Snabbdom

    • 大约200SLOC(single line of code)

    • 通过模块可扩展

    • 源码使用TypeScript开发

    • 最快的Virtual DOM之一



  • virtual-dom




Diff算法


在看完上述的文章之后相信大家已经对Diff算法有一个初步的概念,没错,Diff算法其实就是找出两者之间的差异;



diff 算法首先要明确一个概念就是 Diff 的对象是虚拟DOM(virtual dom),更新真实 DOM 是 Diff 算法的结果。



下面我将会手撕snabbdom源码核心部分为大家打开Diff的心,给点耐心,别关网页,我知道你们都是这样:


src=http___img.wxcha.com_file_201905_17_f5a4d33d48.jpg&refer=http___img.wxcha.jpeg




snabbdom的核心



  • init()设置模块.创建patch()函数

  • 使用h()函数创建JavaScript对象(Vnode)描述真实DOM

  • patch()比较新旧两个Vnode

  • 把变化的内容更新到真实DOM树


init函数


init函数时设置模块,然后创建patch()函数,我们先通过场景案例来有一个直观的体现:


import {init} from 'snabbdom/build/package/init.js'
import {h} from 'snabbdom/build/package/h.js'

// 1.导入模块
import {styleModule} from "snabbdom/build/package/modules/style";
import {eventListenersModule} from "snabbdom/build/package/modules/eventListeners";

// 2.注册模块
const patch = init([
styleModule,
eventListenersModule
])

// 3.使用h()函数的第二个参数传入模块中使用的数据(对象)
let vnode = h('div', [
h('h1', {style: {backgroundColor: 'red'}}, 'Hello world'),
h('p', {on: {click: eventHandler}}, 'Hello P')
])

function eventHandler() {
alert('疼,别摸我')
}

const app = document.querySelector('#app')

patch(app,vnode)

当init使用了导入的模块就能够在h函数中用这些模块提供的api去创建虚拟DOM(Vnode)对象;在上文中就使用了样式模块以及事件模块让创建的这个虚拟DOM具备样式属性以及事件属性,最终通过patch函数对比两个虚拟dom(会先把app转换成虚拟dom),更新视图;


image.png


我们再简单看看init的源码部分:


// src/package/init.ts
/* 第一参数就是各个模块
第二参数就是DOMAPI,可以把DOM转换成别的平台的API,
也就是说支持跨平台使用,当不传的时候默认是htmlDOMApi,见下文
init是一个高阶函数,一个函数返回另外一个函数,可以缓存modules,与domApi两个参数,
那么以后直接只传oldValue跟newValue(vnode)就可以了*/
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {

...

return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {}
}



h函数


些地方也会用createElement来命名,它们是一样的东西,都是创建虚拟DOM的,在上述文章中相信大伙已经对h函数有一个初步的了解并且已经联想了使用场景,就不作场景案例介绍了,直接上源码部分:


// h函数
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}
var children: any
var text: any
var i: number
...
return vnode(sel, data, children, text, undefined) //最终返回一个vnode函数
};

// vnode函数
export function vnode (sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
const key = data === undefined ? undefined : data.key
return { sel, data, children, text, elm, key } //最终生成Vnode对象
}

总结:h函数先生成一个vnode函数,然后vnode函数再生成一个Vnode对象(虚拟DOM对象)


补充:


在h函数源码部分涉及一个函数重载的概念,简单说明一下:



  • 参数个数或参数类型不同的函数()

  • JavaScript中没有重载的概念

  • TypeScript中有重载,不过重载的实现还是通过代码调整参数



重载这个概念个参数相关,和返回值无关




  • 实例1(函数重载-参数个数)



function add(a:number,b:number){

console.log(a+b)

}

function add(a:number,b:number,c:number){

console.log(a+b+c)

}

add(1,2)

add(1,2,3)



  • 实例2(函数重载-参数类型)



function add(a:number,b:number){

console.log(a+b)

}

function add(a:number,b:string){

console.log(a+b)

}

add(1,2)

add(1,'2')




patch函数(核心)


src=http___shp.qpic.cn_qqvideo_ori_0_e3012t7v643_496_280_0&refer=http___shp.qpic.jpeg


要是看完前面的铺垫,看到这里你可能走神了,醒醒啊,这是核心啊,上高地了兄弟;



  • pactch(oldVnode,newVnode)

  • 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点(核心)

  • 对比新旧VNode是否相同节点(节点的key和sel相同)

  • 如果不是相同节点,删除之前的内容,重新渲染

  • 如果是相同节点,再判断新的VNode是否有text,如果有并且和oldVnodetext不同直接更新文本内容(patchVnode)

  • 如果新的VNode有children,判断子节点是否有变化(updateChildren,最麻烦,最难实现)


源码:


return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {    
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
// cbs.pre就是所有模块的pre钩子函数集合
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
// isVnode函数时判断oldVnode是否是一个虚拟DOM对象
if (!isVnode(oldVnode)) {
// 若不是即把Element转换成一个虚拟DOM对象
oldVnode = emptyNodeAt(oldVnode)
}
// sameVnode函数用于判断两个虚拟DOM是否是相同的,源码见补充1;
if (sameVnode(oldVnode, vnode)) {
// 相同则运行patchVnode对比两个节点,关于patchVnode后面会重点说明(核心)
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
elm = oldVnode.elm! // !是ts的一种写法代码oldVnode.elm肯定有值
// parentNode就是获取父元素
parent = api.parentNode(elm) as Node

// createElm是用于创建一个dom元素插入到vnode中(新的虚拟DOM)
createElm(vnode, insertedVnodeQueue)

if (parent !== null) {
// 把dom元素插入到父元素中,并且把旧的dom删除
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))// 把新创建的元素放在旧的dom后面
removeVnodes(parent, [oldVnode], 0, 0)
}
}

for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}

补充1: sameVnode函数


function sameVnode(vnode1: VNode, vnode2: VNode): boolean { 通过key和sel选择器判断是否是相同节点
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}



patchVnode



  • 第一阶段触发prepatch函数以及update函数(都会触发prepatch函数,两者不完全相同才会触发update函数)

  • 第二阶段,真正对比新旧vnode差异的地方

  • 第三阶段,触发postpatch函数更新节点


源码:


function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
if (oldVnode === vnode) return
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
if (isUndef(vnode.text)) { // 新节点的text属性是undefined
if (isDef(oldCh) && isDef(ch)) { // 当新旧节点都存在子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) //并且他们的子节点不相同执行updateChildren函数,后续会重点说明(核心)
} else if (isDef(ch)) { // 只有新节点有子节点
// 当旧节点有text属性就会把''赋予给真实dom的text属性
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
// 并且把新节点的所有子节点插入到真实dom中
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) { // 清除真实dom的所有子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) { // 把''赋予给真实dom的text属性
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) { //若旧节点的text与新节点的text不相同
if (isDef(oldCh)) { // 若旧节点有子节点,就把所有的子节点删除
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
api.setTextContent(elm, vnode.text!) // 把新节点的text赋予给真实dom
}
hook?.postpatch?.(oldVnode, vnode) // 更新视图
}

看得可能有点蒙蔽,下面再上一副思维导图:


image.png




题外话:diff算法简介


传统diff算法



  • 虚拟DOM中的Diff算法

  • 传统算法查找两颗树每一个节点的差异

  • 会运行n1(dom1的节点数)*n2(dom2的节点数)次方去对比,找到差异的部分再去更新


image.png


snabbdom的diff算法优化



  • Snbbdom根据DOM的特点对传统的diff算法做了优化

  • DOM操作时候很少会跨级别操作节点

  • 只比较同级别的节点


image.png


src=http___img.wxcha.com_file_202004_03_1ed2e19e4f.jpg&refer=http___img.wxcha.jpeg


下面我们就会介绍updateChildren函数怎么去对比子节点的异同,也是Diff算法里面的一个核心以及难点;




updateChildren(核中核:判断子节点的差异)



  • 这个函数我分为三个部分,部分1:声明变量,部分2:同级别节点比较,部分3:循环结束的收尾工作(见下图);


image.png



  • 同级别节点比较五种情况:

    1. oldStartVnode/newStartVnode(旧开始节点/新开始节点)相同

    2. oldEndVnode/newEndVnode(旧结束节点/新结束节点)相同

    3. oldStartVnode/newEndVnode(旧开始节点/新结束节点)相同

    4. oldEndVnode/newStartVnode(旧结束节点/新开始节点)相同

    5. 特殊情况当1,2,3,4的情况都不符合的时候就会执行,在oldVnodes里面寻找跟newStartVnode一样的节点然后位移到oldStartVnode,若没有找到在就oldStartVnode创建一个



  • 执行过程是一个循环,在每次循环里,只要执行了上述的情况的五种之一就会结束一次循环

  • 循环结束的收尾工作:直到oldStartIdx>oldEndIdx || newStartIdx>newEndIdx(代表旧节点或者新节点已经遍历完)


为了更加直观的了解,我们再来看看同级别节点比较五种情况的实现细节:


新开始节点和旧开始节点(情况1)


image.png



  • 情况1符合:(从新旧节点的开始节点开始对比,oldCh[oldStartIdx]和newCh[newStartIdx]进行sameVnode(key和sel相同)判断是否相同节点)

  • 则执行patchVnode找出两者之间的差异,更新图;如没有差异则什么都不操作,结束一次循环

  • oldStartIdx++/newStartIdx++


新结束节点和旧结束节点(情况2)


image.png



  • 情况1不符合就判断情况2,若符合:(从新旧节点的结束节点开始对比,oldCh[oldEndIdx]和newCh[newEndIdx]对比,执行sameVnode(key和sel相同)判断是否相同节点)

  • 执行patchVnode找出两者之间的差异,更新视图,;如没有差异则什么都不操作,结束一次循环

  • oldEndIdx--/newEndIdx--


旧开始节点/新结束节点(情况3)


image.png



  • 情况1,2都不符合,就会尝试情况3:(旧节点的开始节点与新节点的结束节点开始对比,oldCh[oldStartIdx]和newCh[newEndIdx]对比,执行sameVnode(key和sel相同)判断是否相同节点)

  • 执行patchVnode找出两者之间的差异,更新视图,如没有差异则什么都不操作,结束一次循环

  • oldCh[oldStartIdx]对应的真实dom位移到oldCh[oldEndIdx]对应的真实dom

  • oldStartIdx++/newEndIdx--;


旧结束节点/新开始节点(情况4)


image.png



  • 情况1,2,3都不符合,就会尝试情况4:(旧节点的结束节点与新节点的开始节点开始对比,oldCh[oldEndIdx]和newCh[newStartIdx]对比,执行sameVnode(key和sel相同)判断是否相同节点)

  • 执行patchVnode找出两者之间的差异,更新视图,如没有差异则什么都不操作,结束一次循环

  • oldCh[oldEndIdx]对应的真实dom位移到oldCh[oldStartIdx]对应的真实dom

  • oldEndIdx--/newStartIdx++;


新开始节点/旧节点数组中寻找节点(情况5)


image.png



  • 从旧节点里面寻找,若寻找到与newCh[newStartIdx]相同的节点(且叫对应节点[1]),执行patchVnode找出两者之间的差异,更新视图,如没有差异则什么都不操作,结束一次循环

  • 对应节点[1]对应的真实dom位移到oldCh[oldStartIdx]对应的真实dom


image.png



  • 若没有寻找到相同的节点,则创建一个与newCh[newStartIdx]节点对应的真实dom插入到oldCh[oldStartIdx]对应的真实dom

  • newStartIdx++


379426071b8130075b11ba142f9468e2.jpeg




下面我们再介绍一下结束循环的收尾工作(oldStartIdx>oldEndIdx || newStartIdx>newEndIdx):


image.png



  • 新节点的所有子节点先遍历完(newStartIdx>newEndIdx),循环结束

  • 新节点的所有子节点遍历结束就是把没有对应相同节点的子节点删除


image.png



  • 旧节点的所有子节点先遍历完(oldStartIdx>oldEndIdx),循环结束

  • 旧节点的所有子节点遍历结束就是在多出来的子节点插入到旧节点结束节点前;(源码:newCh[newEndIdx + 1].elm),就是对应的旧结束节点的真实dom,newEndIdx+1是因为在匹配到相同的节点需要-1,所以需要加回来就是结束节点


最后附上源码:


function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
let oldStartIdx = 0; // 旧节点开始节点索引
let newStartIdx = 0; // 新节点开始节点索引
let oldEndIdx = oldCh.length - 1; // 旧节点结束节点索引
let oldStartVnode = oldCh[0]; // 旧节点开始节点
let oldEndVnode = oldCh[oldEndIdx]; // 旧节点结束节点
let newEndIdx = newCh.length - 1; // 新节点结束节点索引
let newStartVnode = newCh[0]; // 新节点开始节点
let newEndVnode = newCh[newEndIdx]; // 新节点结束节点
let oldKeyToIdx; // 节点移动相关
let idxInOld; // 节点移动相关
let elmToMove; // 节点移动相关
let before;


// 同级别节点比较
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
}
else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
}
else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
}
else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldStartVnode, newStartVnode)) { // 判断情况1
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
}
else if (sameVnode(oldEndVnode, newEndVnode)) { // 情况2
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right情况3
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left情况4
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
else { // 情况5
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key];
if (isUndef(idxInOld)) { // New element // 创建新的节点在旧节点的新节点前
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
}
else {
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) { // 创建新的节点在旧节点的新节点前
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
}
else {
// 在旧节点数组中找到相同的节点就对比差异更新视图,然后移动位置
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined;
api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
// 循环结束的收尾工作
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// newCh[newEndIdx + 1].elm就是旧节点数组中的结束节点对应的dom元素
// newEndIdx+1是因为在之前成功匹配了newEndIdx需要-1
// newCh[newEndIdx + 1].elm,因为已经匹配过有相同的节点了,它就是等于旧节点数组中的结束节点对应的dom元素(oldCh[oldEndIdx + 1].elm)
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
// 把新节点数组中多出来的节点插入到before前
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
}
else {
// 这里就是把没有匹配到相同节点的节点删除掉
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}



key的作用



  • Diff操作可以更加快速;

  • Diff操作可以更加准确;(避免渲染错误)

  • 不推荐使用索引作为key


以下我们看看这些作用的实例:


Diff操作可以更加准确;(避免渲染错误):

实例:a,b,c三个dom元素中的b,c间插入一个z元素


没有设置key
image.png
当设置了key:


image.png


Diff操作可以更加准确;(避免渲染错误)

实例:a,b,c三个dom元素,修改了a元素的某个属性再去在a元素前新增一个z元素


没有设置key:


image.png


image.png


因为没有设置key,默认都是undefined,所以节点都是相同的,更新了text的内容但还是沿用了之前的dom,所以实际上a->z(a原本打勾的状态保留了,只改变了text),b->a,c->b,d->c,遍历完毕发现还要增加一个dom,在最后新增一个text为d的dom元素


设置了key:


image.png


image.png


当设置了key,a,b,c,d都有对应的key,a->a,b->b,c->c,d->d,内容相同无需更新,遍历结束,新增一个text为z的dom元素


不推荐使用索引作为key:

设置索引为key:


image.png


这明显效率不高,我们只希望找出不同的节点更新,而使用索引作为key会增加运算时间,我们可以把key设置为与节点text为一致就可以解决这个问题:


image.png




最后


如有描述错误或者不明的地方请在下方评论联系我,我会立刻更新,如有收获,请为我点个赞👍这是对我的莫大的支持,谢谢各位


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

收起阅读 »

产品经理说你能不能让词云动起来?我觉得配得上!!!

☀️ 前言 事情是这样的,前段时间拿到公司的数据大屏原型图让我一天内把一整个页面做出来。 简单看了看,就是一个3840 * 1840的大屏然后几个列表几个图例看起来也没有多复杂。 唰!很快啊加了一会班把整个页面调整好了信心十足拿给产品经理看。 产品经理皱了皱...
继续阅读 »

☀️ 前言



  • 事情是这样的,前段时间拿到公司的数据大屏原型图让我一天内把一整个页面做出来。

  • 简单看了看,就是一个3840 * 1840的大屏然后几个列表几个图例看起来也没有多复杂。

  • 唰!很快啊加了一会班把整个页面调整好了信心十足拿给产品经理看。

  • 产品经理皱了皱眉头:你这词云不会动啊??


🌤️ 之前的效果



  • 听到这话我发现情况不对,我寻思着这原型图的词云也看不出他要没要求我动啊,而且明明我做的是会动的呀!


🎢 关系图



  • 一开始我用的是echartsgraph关系图,这种图的特点是一开始会因为每个词的斥力会互相分开,在一开始会有一些动态效果,但是因为力引导布局会在多次迭代后才会稳定,所以到后面就不会继续运动了。


ciyun1.gif



  • 我:是吧我没骗人吧?确实是会动的。

  • 产品经理:这样效果不好,没有科技感,而且我要字体大小每个都不同的,明天要拿给客户看一版,比较急,算了你别做动的了就让他词云填满然后每个词的大小要不一样。


WPS图片编辑.png


🎠 词云图



  • 做不动词云的那不就简单了,直接使用echartswordCloud图啊,直接唰唰配置一下就好了。


image.png



  • 产品经理:客户看完了,整体还不错,但是词云这块我还是想它动起来,这样吧,你想个办法整整。


src=http___5b0988e595225.cdn.sohucs.com_images_20181108_0b031f4213f4403ca4cfca30c2b369ca.jpeg&refer=http___5b0988e595225.cdn.sohucs.jpg


🚄 自己手写



  • 对于这个词云,我一开始真的是死脑筋了,认定要用echarts来做,但实际上wordCloud官网也没有提供资料了,好像确实也没有办法让它动起来。

  • 思量片刻....等会,词云要不同大小不同颜色然后要在区域内随机移动,既然我不熟canvas,那我是不是可以用jscss来写一个2d的呢,说白了就是一个词语在一个容器内随机运动然后每个词语都动起来撒,好像能行....开干。


🚅 ToDoList



  • 准备容器和需要的配置项

  • 生成所有静态词云

  • 让词云动起来


🚈 Just Do It



  • 由于我这边的技术栈是vue 2.x的所以接下来会用vue 2.x的语法来分享,但实际上换成原生js也没有什么难度,相信大家可以接受的。


🚎 准备容器和需要的配置项



  • 首先建立一个容器来包裹我们要装的词云,我们接下来的所有操作都围绕这个容器进行。


<template>
<div class="wordCloud" ref="wordCloud">
</div>
</template>

image.png



  • 因为我们的词云需要有不同的颜色我们需要实现准备一个词语列表和颜色列表,再准备一个空数组来存储之后生成的词语。


...
data () {
return {
hotWord: ['万事如意', '事事如意 ', '万事亨通', '一帆风顺', '万事大吉', '吉祥如意', '步步高升', '步步登高', '三羊开泰', '得心应手', '财源广进', '陶未媲美', '阖家安康', '龙马精神'],
color: [
'#a18cd1', '#fad0c4', '#ff8177',
'#fecfef', '#fda085', '#f5576c',
'#330867', '#30cfd0', '#38f9d7'
],
wordArr: []
};
}
...


  • 准备的这些词语都是想对现在在读文章的你说的~如果觉得我说得对的不妨读完文章后给一个 ~

  • 好了不开玩笑,现在准备工作完成了,开始生成我们的词云。


🚒 生成所有静态词云



  • 我们如果想让一个容器里面充满词语,按照正常我们切图的逻辑来说,每个词语占一个span,那么就相当于一个div里面有n(hotWord数量)个词语,也就是容器里面有对应数量的span标签即可。

  • 如果需要不同的颜色和大小,再分别对span标签分别加不同样式即可。


...
mounted () {
this.init();
},
methods: {
init () {
this.dealSpan();
},
dealSpan () {
const wordArr = [];
this.hotWord.forEach((value) => {
// 根据词云数量生成span数量设置字体颜色和大小
const spanDom = document.createElement('span');
spanDom.style.position = 'relative';
spanDom.style.display = "inline-block";
spanDom.style.color = this.randomColor();
spanDom.style.fontSize = this.randomNumber(15, 25) + 'px';
spanDom.innerHTML = value;
this.$refs.wordCloud.appendChild(spanDom);
wordArr.push(spanDom);
});
this.wordArr = wordArr;
},
randomColor () {
// 获取随机颜色
var colorIndex = Math.floor(this.color.length * Math.random());
return this.color[colorIndex];
},
randomNumber (lowerInteger, upperInteger) {
// 获得一个包含最小值和最大值之间的随机数。
const choices = upperInteger - lowerInteger + 1;
return Math.floor(Math.random() * choices + lowerInteger);
}
}
...


  • 我们对hotWord热词列表进行遍历,每当有一个词语就生成一个span标签,分别使用randomColor()randomSize()设置不同的随机颜色和大小。

  • 最后再将这些span都依次加入div容器中,那么完成后是这样的。


image.png


🚓 让词云动起来



  • 词语是添加完了,接下来我们需要让他们动起来,那么该怎么动呢,我们自然而然会想到transformtranslateXtranslateY属性,我们首先要让一个词语先动起来,接下来所有的都应用这种方式就可以了。


先动一下x轴


  • 怎么动呢?我们现在要做的是一件无限循环的事情,就是一个元素无限的移动,既然是无限,在js中用定时器可不可以实现呢?确实是可以的,但是会巨卡,万一词语一多你的电脑会爆炸,在另一方面编写动画循环的关键是要知道延迟时间多长合适,如果太长或者太短都不合适所以不用定时器。

  • 然后一不小心发现了window.requestAnimationFrame这个APIrequestAnimationFrame不需要设置时间间隔。



requestAnimationFrame 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒60帧。




  • 也就是说当我们循环无限的让一个元素在x轴或者y轴移动,假设每秒向右移动10px那么它的translateX就是累加10px,每个元素都是如此那么我们需要给span元素新增一个属性来代表它的位置。


data () {
return {
...
timer: null,
resetTime: 0
...
};
}
methods: {
init () {
this.dealSpan();
this.render();
},
dealSpan () {
const wordArr = [];
this.hotWord.forEach((value) => {
...
spanDom.local = {
position: {
x: 0,
y: 0
}
};
...
});
this.wordArr = wordArr;
},
render () {
if (this.resetTime < 100) {
//防止“栈溢出”
this.resetTime = this.resetTime + 1;
this.timer = requestAnimationFrame(this.render.bind(this));
this.resetTime = 0;
}
this.wordFly();
},
wordFly () {
this.wordArr.forEach((value) => {
//每次循环加1
value.local.position.x += 1;
// 给每个词云加动画过渡
value.style.transform = 'translateX(' + value.local.position.x + 'px)';
});
},
},
destroyed () {
// 组件销毁,关闭定时执行
cancelAnimationFrame(this.timer);
},


  • 这时候我们给每个元素加了个local属性里面有它的初始位置,每当我们执行一次requestAnimationFrame的时候它的初始位置+1,再把这个值给到translateX这样我们每次循环都相当于移动了1px,现在我们来看看效果。


ciyun2.gif


调整范围


  • 嘿!好家伙,动是动起来了,但是怎么还过头了呢?

  • 我们发现每次translateX+1了但是没有给一个停止的范围给他,所以我们需要给一个让他到容器的边缘就开始掉头的步骤。

  • 那怎么样让他掉头呢?既然我们可以让他每次往右移动1px那么我们是不是可以检测到当它的x轴位置大于这个容器的位置时x轴位置小于这个容器的位置时并且换个方向就好换个方向我们只需要用正负数来判断即可。


init () {
this.dealSpan();
this.initWordPos();
this.render();
},
dealSpan () {
const wordArr = [];
this.hotWord.forEach((value) => {
...
spanDom.local = {
position: {
// 位置
x: 0,
y: 0
},
direction: {
// 方向 正数往右 负数往左
x: 1,
y: 1
}
};
...
});
this.wordArr = wordArr;
},
wordFly () {
this.wordArr.forEach((value) => {
// 设置运动方向 大于边界或者小于边界的时候换方向
if (value.local.realPos.minx + value.local.position.x < this.ContainerSize.leftPos.x) {
value.local.direction.x = -value.local.direction.x;
}
if (value.local.realPos.maxx + value.local.position.x > this.ContainerSize.rightPos.x) {
value.local.direction.x = -value.local.direction.x;
}
//每次右移1个单位 当方向为负数时就是-1个单位也就是向左移1个单位
value.local.position.x += 1 * value.local.direction.x;
// 给每个词云加动画过渡
value.style.transform = 'translateX(' + value.local.position.x + 'px)';
});
},
initWordPos () {
// 计算每个词的真实位置和容器的位置
this.wordArr.forEach((value) => {
value.local.realPos = {
minx: value.offsetLeft,
maxx: value.offsetLeft + value.offsetWidth
};
});
this.ContainerSize = this.getContainerSize();
},
getContainerSize () {
// 判断容器大小控制词云位置
const el = this.$refs.wordCloud;
return {
leftPos: {
// 容器左侧的位置和顶部位置
x: el.offsetLeft,
y: el.offsetTop
},
rightPos: {
// 容器右侧的位置和底部位置
x: el.offsetLeft + el.offsetWidth,
y: el.offsetTop + el.offsetHeight
}
};
}


  • 我们一开始先用initWordPos来计算每个词语现在处于的位置并把它的位置保存起来,再使用getContainerSize获取我们的外部容器的最左侧最右侧最上最下的位置保存起来。

  • 给我们每个span添加一个属性direction 方向,当方向为负数则往左,方向为正则往右,注释我写在代码上了,大家如果不清除可以看一下。

  • 也就是说我们的词云会在容器里面反复横跳,那我们来看看效果。


ciyun3.gif


随机位移


  • 很不错,是我们想要的效果!!!

  • 当然我们每次位移不可能写死只位移1px我们要做到那种凌乱美,那就需要做一个随机位移。

  • 那怎么来做随机位移呢?可以看出我们的词语其实是在做匀速直线运动而匀速直线运动的公式大家还记得吗?

  • 如果不记得的话这边建议回去翻一下物理书~ 匀速直线运动的位移公式是 x=vt

  • 这个x就是我们需要的位移,而这个t我们就不用管了因为我上面也说了这个requestAnimationFrame会帮助我们设置时间,那我们只需要控制这个v初速度是随机的就可以了。


dealSpan () {
const wordArr = [];
this.hotWord.forEach((value) => {
...
spanDom.local = {
velocity: {
// 每次位移初速度
x: -0.5 + Math.random(),
y: -0.5 + Math.random()
},
};
...
});
this.wordArr = wordArr;
},
wordFly () {
this.wordArr.forEach((value) => {
...
//利用公式 x=vt
value.local.position.x += value.local.velocity.x * value.local.direction.x;
...
});
},


  • 我们给每个词语span元素一个初速度,这个初速度可以为- 也可以为+代表向左或者向右移动,当我们处理这个translateX的时候他就会随机处理了,现在我们来看看效果。


ciyun4.gif


完善y轴


  • 现在x轴已经按照我们想的所完成了,想让词云们上下左右都动起来那么我们需要按照x轴的方法来配一下y轴即可。

  • 由于代码长度问题我就不放出来啦,我下面会给出源码,大家有兴趣可以去下载看看~我们直接来看看成品!小卢感谢您的阅读,那我就在这里祝您


ciyun5.gif



  • 至此一个简单的词云动画就完啦,具体源码我放在这里。

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

收起阅读 »

贝塞尔曲线在前端,走近她,然后爱上她

贝塞尔曲线在前端 css3的动画主要是 transition animation transition有transition-timing-function animation有animation-timing-function 以transition-t...
继续阅读 »

贝塞尔曲线在前端


css3的动画主要是



  • transition

  • animation


transition有transition-timing-function

animation有animation-timing-function


transition-timing-function为例


image.png


其内置 ease,linear,ease-in,ease-out,ease-in-out就是贝塞尔曲线函数, 作用是控制属性变化的速度。

也可以自定义cubic-bizier(x1,y1,x2,y2), 这个嘛玩意呢,三阶贝塞尔曲线, x1,y1x2,y2是两个控制点。


如图:
x1, y1对应 P1点, x2,y2 对应P2点。

要点:



  1. 曲线越陡峭,速度越快,反之,速度越慢!

  2. 控制点的位置会影响曲线形状


image.png




说道这里, 回想一下我们前端在哪些地方还会贝塞尔呢。



  • svg

  • canvas/webgl

  • css3 动画

  • animation Web API

    千万别以为JS就不能操作CSS3动画了


这样说可能有些空洞,我们一起来看看曲线和实际的动画效果:

红色ease和ease-out曲线前期比较陡峭,加速度明显比较快


图片.png


贝塞尔曲线运动-演示地址
6af390fc619a4f1f8758a437d03e37c4~tplv-k3u1fbpfcp-watermark.image.gif




什么是贝赛尔曲线


贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。


公式怎么理解呢?这里你可以假定



  • P0的坐标(0,0), 最终的点的坐标为(1,1)


t从0不断的增长到1

t的值和控制点的x坐标套入公式,得到一个新的x坐标值

t的值和控制点的y坐标套入公式,得到一个新的y坐标值


(新的x坐标值 , 新的y坐标值)坐标就是t时刻曲线的点的坐标。


通用公式


image.png


线性公式


无控制点,直线


image.png


二次方公式


一个控制点


image.png


三次方公式


两个控制点


image.png


这是我们的重点,因为css动画都是三次方程式


P0作为起点,P3作为终点, 控制点是P1与P2, 因为我们一般会假定 P0 为 (0,0), 而 P3为(1,1)。


控制点的变化,会影响整个曲线,我们一起来简单封装一下并进行实例操作。


一阶二阶三阶封装


我们基于上面公式的进行简单的封装,

你传入需要的点数量和相应的控制点就能获得相应一组点的信息。


class Bezier {
getPoints(count = 100, ...points) {
const len = points.length;
if (len < 2 || len > 4) {
throw new Error("参数points的长度应该大于等于2小于5");
}
const fn =
len === 2
? this.firstOrder
: len === 3
? this.secondOrder
: this.thirdOrder;
const retPoints = [];
for (let i = 0; i < count; i++) {
retPoints.push(fn.call(null, i / count, ...points));
}
return retPoints;
}

firstOrder(t, p0, p1) {
const { x: x0, y: y0 } = p0;
const { x: x1, y: y1 } = p1;
const x = (x1 - x0) * t;
const y = (y1 - y0) * t;
return { x, y };
}

secondOrder(t, p0, p1, p2) {
const { x: x0, y: y0 } = p0;
const { x: x1, y: y1 } = p1;
const { x: x2, x: y2 } = p2;
const x = (1 - t) * (1 - t) * x0 + 2 * t * (1 - t) * x1 + t * t * x2;
const y = (1 - t) * (1 - t) * y0 + 2 * t * (1 - t) * y1 + t * t * y2;
return { x, y };
}

thirdOrder(t, p0, p1, p2, p3) {
const { x: x0, y: y0 } = p0;
const { x: x1, y: y1 } = p1;
const { x: x2, y: y2 } = p2;
const { x: x3, y: y3 } = p3;
let x =
x0 * Math.pow(1 - t, 3) +
3 * x1 * t * (1 - t) * (1 - t) +
3 * x2 * t * t * (1 - t) +
x3 * t * t * t;
let y =
y0 * (1 - t) * (1 - t) * (1 - t) +
3 * y1 * t * (1 - t) * (1 - t) +
3 * y2 * t * t * (1 - t) +
y3 * t * t * t;
return { x, y };
}
}

export default new Bezier();


演示地址: xiangwenhu.github.io/juejinBlogs…


一阶贝塞尔是一条直线:

image.png


二阶贝塞尔一个控制点:


image.png


三阶贝塞尔两个控制点:


image.png


贝塞尔曲线控制点


回到最开始, animation和 transition都可以自定义三阶贝塞尔函数, 而需要的就是两个控制点的信息怎么通过测试曲线获得控制点呢?


在线取三阶贝塞尔关键的方案早就有了。



在线贝塞尔

在线贝塞尔2



但是不妨碍我自己去实现一个简单,加强理解。

大致的实现思路



  • canvas 绘制效果

    canvas有bezierCurveTo方法,直接可以绘制贝塞尔曲线

  • 两个控制点用dom元素来显示


逻辑



  • 点击时计算最近的点,同时修改最近点的坐标

  • 重绘


当然这只是一个简单的版本。


演示地址: xiangwenhu.github.io/juejinBlogs…

截图:


有了这个,你就可以通过曲线获得控制点了, 之前提到过,曲线的陡峭决定了速度的快慢,是不是很有用呢?


当然,你可以自己加个贝塞尔的直线运动,查看实际的运动效果,其实都不难,难的是你不肯动手!!!


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

收起阅读 »

我用index作为key也没啥问题啊,为什么面试还有人diao我???

所有熟悉 Vue 技术栈的小伙伴,都知道在列表渲染的场景下,不能使用 index 或 random 作为 key。 也有很多小伙伴在面试的时候会被面试官比较详细的追问,假如使用 index 作为 key 会有什么问题?假如使用 random 作为 key 会有...
继续阅读 »

所有熟悉 Vue 技术栈的小伙伴,都知道在列表渲染的场景下,不能使用 indexrandom 作为 key


也有很多小伙伴在面试的时候会被面试官比较详细的追问,假如使用 index 作为 key 会有什么问题?假如使用 random 作为 key 会有什么问题?假如使用一个唯一不变的 id 作为 key 有什么好处呢?


这道题目,表面上看起来是考察我们对同级比较过程中 diff 算法的理解,唯一不变的 key 可以帮助我们更快的找到可复用的 VNode,节省性能开销,使用 index 作为 key 有可能造成 VNode 错误的复用,从而产生 bug ,而使用 random 作为 key 会导致VNode 始终无法复用,极大的影响性能。


这么回答有问题么?没有问题。


但是假如这道题目满分100,我只能给你99分。


还有 1分,涉及到 Vue 更新流程中的一点点细节,若不理清,可能在实际的业务场景中给我们造成困扰。


啥困扰呢?


举个栗子


直奔主题,看一段代码,index 作为 key ,假如我们删除某一条,结果会是啥呢?


<template>
 <div id="app">
   <div v-for="(item, index) in data" :key="index">
     <Child />
     <button @click="handleDelete(index)">删除这一行</button>
   </div>
 </div>
</template>

<script>

export default {
 name: "App",
 components: {
   Child: {
     template: '<span>{{name}}{{Math.floor(Math.random() * 1000)}}</span>',
     props: ['name']
  }
},
 data() {
   return {
     data: [
      { name: "小明" },
      { name: "小红" },
      { name: "小蓝" },
      { name: "小紫" },
    ]
  };
},
 methods: {
   handleDelete(index) {
     this.data.splice(index, 1);
  },
}
};
</script>

看结果



可以观察到,虽然我们删除的不是最后一条,但最终却是最后一条被删除了,看起来很奇怪,但是假如你了解过 Vuediff 流程,这个结果应该是可以符合你的预期的。


diff


大段的列源码,会增加我们的理解负担,所以我把 Vue更新流程简化成一张图:



通常来讲,我们说 Vuediff 流程,指的就是 patchVnode ,其中 updateChildren 就是我们说的同层比较,其实就是比较新旧两个 Vnode 数组。


Vue 会声明四个指针变量,分别记录新旧 Vnode 数组的首尾索引,通过首尾索引指针的移动,根据新头旧头、新尾旧尾、旧头新尾、旧尾新头的顺序,依次比较新旧 Vnode ,若不能命中 sameVnode,则将oldVnode.key 维护成一个 map, 继续查询是否包含newVnode.key ,若命中 sameVnode ,则递归执行 patchVnode。若最终无法命中,说明无可复用的 Vnode ,创建新的 dom 节点。


newVnode 的首尾指针先相遇,说明 newVnode 已经遍历完成,直接移除 oldVnode 多余部分,若 oldVnode 的首尾指针先相遇,说明 oldVnode 已经遍历完成,直接新增 newVnode 的多余部分。


这种直接的文字描述会显得比较苍白,所以我给大家准备了个动画


第一步:



第二步:



第三步:



第四步:



第五步:



第六步:



理论上,只要你滑动的足够快,这几张图就可以动起来😊



上面描述updateChildren过程的图片均摘自 Vue技术揭秘 组件更新章节,建议大家翻阅原文


我尝试了半天实在做不出来动画,同时感觉这几张图已经可以带给我们足够直观的感受了,所以直接搬运了


侵删



使用 index 作为 key 会有什么问题


上面我们讲,判断新旧 Vnode 是否可以复用,取决于 sameNode 方法,这个方法非常简单,就是比对 Vnode 的部分属性,其中 key 是最关键的因素


function sameVnode (a, b) {
   return (
     a.key === b.key &&
     a.asyncFactory === b.asyncFactory && (
      (
         a.tag === b.tag &&
         a.isComment === b.isComment &&
         isDef(a.data) === isDef(b.data) &&
         sameInputType(a, b)
      ) || (
         isTrue(a.isAsyncPlaceholder) &&
         isUndef(b.asyncFactory.error)
      )
    )
  )
}

我们再回到上面的栗子,看看是哪里出了问题


上面代码生成的 VNode 大约是这样的:


[
{
   tag: 'div',
key: 0,
children: [
{
tag: VueComponent,
       elm: 408, // 这个Vnode对应的真实dom是408
},
{
tag: 'button'
}
]
},
{
   tag: 'div',
key: 1,
children: [
{
tag: VueComponent,
       elm: 227, // 这个Vnode对应的真实dom是227
},
{
tag: 'button'
}
]
}
 ...
]

我们删除第一条数据,新的 VNode 大约是这样的:


[
{
   tag: 'div',
key: 0,
children: [
{
tag: VueComponent,
       elm: 227, // 这个Vnode对应的真实dom是227
},
{
tag: 'button'
}
]
},
{
   tag: 'div',
key: 1,
children: [
{
tag: VueComponent,
       elm: 324, // 这个Vnode对应的真实dom是324
},
{
tag: 'button'
}
]
}
 ...
]

我们人肉逻辑 一下这两个 Vnode 数组,由于 key 都是0,所以比较第一条的时候,就会命中 sameNode ,导致错误复用,然后 updateChildren ,子节点的 Vnode 依然会命中 sameVnode ,同理,第二、三条均会命中 sameVnode ,而直接错误复用其关联的真实 dom 节点,所以我们明明删除的是第一条,UI表现却是最后一条被删除了。


那么到这里就结束了么?


当然没有,因为很多小伙伴在刚接触 Vue 的时候,也用过 index 作为 key ,部分牛逼的项目甚至已经上线了,似乎也没人来找麻烦


why?


为什么我用 index 作为 key 没出现问题


如果我把代码改成这样,再删除某一条,会是什么结果呢?


<template>
 <div id="app">
   <div v-for="(item, index) in data" :key="index">
     <Child :name="`${item.name}`" />
     <button @click="handleDelete(index)">删除这一行</button>
   </div>
 </div>
</template>

看结果



法克,我们明明把 Vue更新流程捋清楚了,用 index 作为 key 会导致 Vnode 错误复用啊,怎么这里表现却正常了呢?


我们再看一下更新流程简化图:



组件类型的 Vnode ,在 patchVnode 的过程中会执行 prePatch 钩子函数,给组件的 propsData 重新赋值,从而触发 setter ,假如 propsData 的值有变化,则会触发 update ,重新渲染组件


我们可以再人肉逻辑 一下,这次我们删除的是第二条,因为key 一致,新的 Vnode 数组依然会复用旧的 Vnode 数组的前三条,第一条 Vnode 是正确复用,组件的 propsData 未发生变化,不会触发 update ,直接复用其关联的真实 dom 节点,但是第二条 Vnode 是错误复用,但是组件的 propsData 发生变化,由小红变成了小蓝,触发了 update ,组件重新渲染,因此我们看到其实连 random 都发生了变化,第三条同理。


呼~


到这里,总算是搞明白了,我可真是个小机灵鬼


那么到这里就结束了么?


其实还没有,比如我们再改一下代码


<template>
 <div id="app">
   <div v-for="(item, index) in data" :key="index">
     <span>{{item.name}}</span>
     <button @click="handleDelete(index)">删除这一行</button>
   </div>
 </div>
</template>

看结果



这次我们没有组件类型 Vnode ,不会执行 prePatch,为啥表现还是正常的呢?


再观察一下上面的更新流程图,文本类型的 Vnode ,新旧文本不同的时候是会直接覆盖的。


到这里,我们已经完全明白,列表渲染的场景下,为什么推荐使用唯一不变的 id 作为 key了。抛开代码规范不谈,即使某些场景下,问题并未以 bug 的形式暴露出来,但是不能复用、或者错误复用 Vnode ,都会导致组件重新渲染,这部分的性能包袱还是非常沉重的!


最后的1分


纸上得来终觉浅,绝知此事要躬行


我第一次读完 Vue2 源码的时候,以为自己已经清晰的明白了这部分知识,直到团队里的小伙伴拿着一个纯文本类型的列表来质问我


不得已仔细 debug 了一遍更新流程,才算解开了心中疑惑,补上了这 1分 的缺口



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

收起阅读 »

call, call.call, call.call.call, 你也许还不懂这疯狂的call

Function.prototype.call 我想大家都觉得自己很熟悉了,手写也没问题!! 你确认这个问题之前, 首先看看 三千文字,也没写好 Function.prototype.call, 看完,你感觉还OK,那么再看一道题: 请问如下的输出结果 fun...
继续阅读 »

Function.prototype.call 我想大家都觉得自己很熟悉了,手写也没问题!!

你确认这个问题之前, 首先看看 三千文字,也没写好 Function.prototype.call,


看完,你感觉还OK,那么再看一道题:

请问如下的输出结果


function a(){ 
console.log(this,'a')
};
function b(){
console.log(this,'b')
}
a.call.call(b,'b')

如果,你也清晰的知道,结果,对不起,大佬, 打扰了,我错了!


本文起源:

一个掘友加我微信,私聊问我这个问题,研究后,又请教了 阿宝哥

觉得甚有意思,遂与大家分享!


结果


结果如下: 惊喜还是意外,还是淡定呢?


String {"b"} "b"

再看看如下的代码:2个,3个,4个,更多个的call,输出都会是String {"b"} "b"


function a(){ 
console.log(this,'a')
};
function b(){
console.log(this,'b')
}
a.call.call(b,'b') // String {"b"} "b"
a.call.call.call(b,'b') // String {"b"} "b"
a.call.call.call.call(b,'b') // String {"b"} "b"

看完上面,应该有三个疑问?



  1. 为什么被调用的是b函数

  2. 为什么thisString {"b"}

  3. 为什么 2, 3, 4个call的结果一样


结论:

两个以上的call,比如call.call(b, 'b'),你就简单理解为用 b.call('b')


分析


为什么 2, 3, 4个call的结果一样


a.call(b) 最终被调用的是a,

a.call.call(b), 最终被调用的 a.call

a.call.call.call(b), 最终被执行的 a.call.call


看一下引用关系


a.call === Function.protype.call  // true
a.call === a.call.call // true
a.call === a.call.call.call // true

基于上述执行分析:

a.call 被调用的是a

a.call.calla.call.call.call 本质没啥区别, 被调用的都是Function.prototype.call


为什么 2, 3, 4个call的结果一样,到此已经真相


为什么被调用的是b函数


看本质就要返璞归真,ES 标准对 Funtion.prototye.call 的描述



Function.prototype.call (thisArg , ...args)


When the call method is called on an object func with argument, thisArg and zero or more args, the following steps are taken:



  1. If IsCallable(func) is false, throw a TypeError exception.

  2. Let argList be an empty List.

  3. If this method was called with more than one argument then in left to right order, starting with the second argument, append each argument as the last element of argList.

  4. Perform PrepareForTailCall().

  5. Return Call(functhisArgargList).



中文翻译一下



  1. 如果不可调用,抛出异常

  2. 准备一个argList空数组变量

  3. 把第一个之后的变量按照顺序添加到argList

  4. 返回 Call(functhisArgargList)的结果


这里的Call只不是是一个抽象的定义, 实际上是调用函数内部 [[Call]] 的方法, 其也没有暴露更多的有用的信息。


实际上在这里,我已经停止了思考:


a is a function, then what a.call.call really do? 一文的解释,有提到 Bound Function Exotic Objects , MDN的 Function.prototype.bind 也有提到:



The bind() function creates a new bound function, which is an exotic function object (a term from ECMAScript 2015) that wraps the original function object. Calling the bound function generally results in the execution of its wrapped function.



Function.prototype.call 相反,并没有提及!!! 但不排查在调用过程中有生成。


Difference between Function.call, Function.prototype.call, Function.prototype.call.call and Function.prototype.call.call.call 一文的解释,我觉得是比较合理的


function my(p) { console.log(p) }
Function.prototype.call.call(my, this, "Hello"); // output 'Hello'


Function.prototype.call.call(my, this, "Hello"); means:


Use my as this argument (the function context) for the function that was called. In this case Function.prototype.call was called.


So, Function.prototype.call would be called with my as its context. Which basically means - it would be the function to be invoked.


It would be called with the following arguments: (this, "Hello"), where this is the context to be set inside the function to be called (in this case it's my), and the only argument to be passed is "Hello" string.



重点标出:

So, Function.prototype.call would be called with my as its context. Which basically means - it would be the function to be invoked.


It would be called with the following arguments: (this, "Hello"), where this is the context to be set inside the function to be called (in this case it's my), and the only argument to be passed is "Hello" string


翻译一下:

Function.prototype.call.call(my, this, "Hello")表示: 用my作为上下文调用Function.prototype.call,也就是说my是最终被调用的函数。


my带着这些 (this, "Hello") 被调用, this 作为被调用函数的上下文,此处是作为my函数的上下文, 唯一被传递的参数是 "hello"字符串。


基于这个理解, 我们简单验证一下, 确实是这样的表象


// case 1:
function my(p) { console.log(p) }
Function.prototype.call.call(my, this, "Hello"); // output 'Hello'

// case 2:
function a(){
console.log(this,'a')
};
function b(){
console.log(this,'b')
}
a.call.call(b,'b') // String {"b"} "b"

为什么被调用的是b函数, 到此也真相了。


其实我依旧不能太释怀, 但是这个解释可以接受,表象也是正确的, 期望掘友们有更合理,更详细的解答。


为什么thisString {"b"}


在上一节的分析中,我故意遗漏了Function.prototype.call的两个note



NOTE 1: The thisArg value is passed without modification as the this value. This is a change from Edition 3, where an undefined or null thisArg is replaced with the global object and ToObject is applied to all other values and that result is passed as the this value. Even though the thisArg is passed without modification, non-strict functions still perform these transformations upon entry to the function.




NOTE 2: If func is an arrow function or a bound function then the thisArg will be ignored by the function [[Call]] in step 5.



注意这一句:



This is a change from Edition 3, where an undefined or null thisArg is replaced with the global object and ToObject is applied to all other values and that result is passed as the this value



两点:



  1. 如果thisArgundefined 或者null, 会用global object替换


这里的前提是 非严格模式


"use strict"

function a(m){
console.log(this, m); // undefined, 1
}

a.call(undefined, 1)


  1. 其他的所有类型,都会调用 ToObject进行转换


所以非严格模式下, this肯定是个对象, 看下面的代码:


Object('b') // String {"b"}

note2的 ToObject 就是答案


到此, 为什么thisSting(b) 这个也真相了


万能的函数调用方法


基于Function.prototype.call.call的特性,我们可以封装一个万能函数调用方法


var call = Function.prototype.call.call.bind(Function.prototype.call);

示例


var person = {
hello() {
console.log('hello', this.name)
}
}

call(person.hello, {"name": "tom"}) // hello tom

写在最后


如果你觉得不错,你的一赞一评就是我前行的最大动力。




作者:云的世界
链接:https://juejin.cn/post/6999781802923524132

收起阅读 »

Vue3的7种和Vue2的12种组件通信,年轻人?还不收藏在等什么!!!

Vue2.x组件通信12种方式写在后面了,先来 Vue3 的 奥力给! Vue3 组件通信方式 props $emit expose / ref $attrs v-model provide / inject Vuex Vue3 通信使用写法 props ...
继续阅读 »

Vue2.x组件通信12种方式写在后面了,先来 Vue3 的


奥力给!


Vue3 组件通信方式



  • props

  • $emit

  • expose / ref

  • $attrs

  • v-model

  • provide / inject

  • Vuex


Vue3 通信使用写法


props


用 props 传数据给子组件有两种方法,如下


方法一,混合写法


// Parent.vue 传送
<child :msg1="msg1" :msg2="msg2"></child>
<script>
import child from "./child.vue"
import { ref, reactive } from "vue"
export default {
data(){
return {
msg1:"这是传级子组件的信息1"
}
},
setup(){
// 创建一个响应式数据

// 写法一 适用于基础类型 ref 还有其他用处,下面章节有介绍
const msg2 = ref("这是传级子组件的信息2")

// 写法二 适用于复杂类型,如数组、对象
const msg2 = reactive(["这是传级子组件的信息2"])

return {
msg2
}
}
}
</script>

// Child.vue 接收
<script>
export default {
props: ["msg1", "msg2"],// 如果这行不写,下面就接收不到
setup(props) {
console.log(props) // { msg1:"这是传给子组件的信息1", msg2:"这是传给子组件的信息2" }
},
}
</script>

方法二,纯 Vue3 写法


// Parent.vue 传送
<child :msg2="msg2"></child>
<script setup>
import child from "./child.vue"
import { ref, reactive } from "vue"
const msg2 = ref("这是传给子组件的信息2")
// 或者复杂类型
const msg2 = reactive(["这是传级子组件的信息2"])
</script>

// Child.vue 接收
<script setup>
// 不需要引入 直接使用
// import { defineProps } from "vue"
const props = defineProps({
// 写法一
msg2: String
// 写法二
msg2:{
type:String,
default:""
}
})
console.log(props) // { msg2:"这是传级子组件的信息2" }
</script>

注意:


如果父组件是混合写法,子组件纯 Vue3 写法的话,是接收不到父组件里 data 的属性,只能接收到父组件里 setup 函数里传的属性


如果父组件是纯 Vue3 写法,子组件混合写法,可以通过 props 接收到 data 和 setup 函数里的属性,但是子组件要是在 setup 里接收,同样只能接收到父组件中 setup 函数里的属性,接收不到 data 里的属性


官方也说了,既然用了 3,就不要写 2 了,所以不推荐混合写法。下面的例子,一律只用纯 Vue3 的写法,就不写混合写法了


$emit


// Child.vue 派发
<template>
// 写法一
<button @click="emit('myClick')">按钮</buttom>
// 写法二
<button @click="handleClick">按钮</buttom>
</template>
<script setup>

// 方法一 适用于Vue3.2版本 不需要引入
// import { defineEmits } from "vue"
// 对应写法一
const emit = defineEmits(["myClick","myClick2"])
// 对应写法二
const handleClick = ()=>{
emit("myClick", "这是发送给父组件的信息")
}

// 方法二 不适用于 Vue3.2版本,该版本 useContext()已废弃
import { useContext } from "vue"
const { emit } = useContext()
const handleClick = ()=>{
emit("myClick", "这是发送给父组件的信息")
}
</script>

// Parent.vue 响应
<template>
<child @myClick="onMyClick"></child>
</template>
<script setup>
import child from "./child.vue"
const onMyClick = (msg) => {
console.log(msg) // 这是父组件收到的信息
}
</script>

expose / ref


父组件获取子组件的属性或者调用子组件方法


// Child.vue
<script setup>
// 方法一 不适用于Vue3.2版本,该版本 useContext()已废弃
import { useContext } from "vue"
const ctx = useContext()
// 对外暴露属性方法等都可以
ctx.expose({
childName: "这是子组件的属性",
someMethod(){
console.log("这是子组件的方法")
}
})

// 方法二 适用于Vue3.2版本, 不需要引入
// import { defineExpose } from "vue"
defineExpose({
childName: "这是子组件的属性",
someMethod(){
console.log("这是子组件的方法")
}
})
</script>

// Parent.vue 注意 ref="comp"
<template>
<child ref="comp"></child>
<button @click="handlerClick">按钮</button>
</template>
<script setup>
import child from "./child.vue"
import { ref } from "vue"
const comp = ref(null)
const handlerClick = () => {
console.log(comp.value.childName) // 获取子组件对外暴露的属性
comp.value.someMethod() // 调用子组件对外暴露的方法
}
</script>

attrs


attrs:包含父作用域里除 class 和 style 除外的非 props 属性集合


// Parent.vue 传送
<child :msg1="msg1" :msg2="msg2" title="3333"></child>
<script setup>
import child from "./child.vue"
import { ref, reactive } from "vue"
const msg1 = ref("1111")
const msg2 = ref("2222")
</script>

// Child.vue 接收
<script setup>
import { defineProps, useContext, useAttrs } from "vue"
// 3.2版本不需要引入 defineProps,直接用
const props = defineProps({
msg1: String
})
// 方法一 不适用于 Vue3.2版本,该版本 useContext()已废弃
const ctx = useContext()
// 如果没有用 props 接收 msg1 的话就是 { msg1: "1111", msg2:"2222", title: "3333" }
console.log(ctx.attrs) // { msg2:"2222", title: "3333" }

// 方法二 适用于 Vue3.2版本
const attrs = useAttrs()
console.log(attrs) // { msg2:"2222", title: "3333" }
</script>

v-model


可以支持多个数据双向绑定


// Parent.vue
<child v-model:key="key" v-model:value="value"></child>
<script setup>
import child from "./child.vue"
import { ref, reactive } from "vue"
const key = ref("1111")
const value = ref("2222")
</script>

// Child.vue
<template>
<button @click="handlerClick">按钮</button>
</template>
<script setup>

// 方法一 不适用于 Vue3.2版本,该版本 useContext()已废弃
import { useContext } from "vue"
const { emit } = useContext()

// 方法二 适用于 Vue3.2版本,不需要引入
// import { defineEmits } from "vue"
const emit = defineEmits(["key","value"])

// 用法
const handlerClick = () => {
emit("update:key", "新的key")
emit("update:value", "新的value")
}
</script>

provide / inject


provide / inject 为依赖注入


provide:可以让我们指定想要提供给后代组件的数据或


inject:在任何后代组件中接收想要添加在这个组件上的数据,不管组件嵌套多深都可以直接拿来用


// Parent.vue
<script setup>
import { provide } from "vue"
provide("name", "沐华")
</script>

// Child.vue
<script setup>
import { inject } from "vue"
const name = inject("name")
console.log(name) // 沐华
</script>

Vuex


// store/index.js
import { createStore } from "vuex"
export default createStore({
state:{ count: 1 },
getters:{
getCount: state => state.count
},
mutations:{
add(state){
state.count++
}
}
})

// main.js
import { createApp } from "vue"
import App from "./App.vue"
import store from "./store"
createApp(App).use(store).mount("#app")

// Page.vue
// 方法一 直接使用
<template>
<div>{{ $store.state.count }}</div>
<button @click="$store.commit('add')">按钮</button>
</template>

// 方法二 获取
<script setup>
import { useStore, computed } from "vuex"
const store = useStore()
console.log(store.state.count) // 1

const count = computed(()=>store.state.count) // 响应式,会随着vuex数据改变而改变
console.log(count) // 1
</script>

Vue2.x 组件通信方式


Vue2.x 组件通信共有12种



  1. props

  2. $emit / v-on

  3. .sync

  4. v-model

  5. ref

  6. $children / $parent

  7. $attrs / $listeners

  8. provide / inject

  9. EventBus

  10. Vuex

  11. $root

  12. slot


父子组件通信可以用:



  • props

  • $emit / v-on

  • $attrs / $listeners

  • ref

  • .sync

  • v-model

  • $children / $parent


兄弟组件通信可以用:



  • EventBus

  • Vuex

  • $parent


跨层级组件通信可以用:



  • provide/inject

  • EventBus

  • Vuex

  • $attrs / $listeners

  • $root


Vue2.x 通信使用写法


下面把每一种组件通信方式的写法一一列出


1. props


父组件向子组件传送数据,这应该是最常用的方式了


子组件接收到数据之后,不能直接修改父组件的数据。会报错,所以当父组件重新渲染时,数据会被覆盖。如果子组件内要修改的话推荐使用 computed


// Parent.vue 传送
<template>
<child :msg="msg"></child>
</template>

// Child.vue 接收
export default {
// 写法一 用数组接收
props:['msg'],
// 写法二 用对象接收,可以限定接收的数据类型、设置默认值、验证等
props:{
msg:{
type:String,
default:'这是默认数据'
}
},
mounted(){
console.log(this.msg)
},
}

2. .sync


可以帮我们实现父组件向子组件传递的数据 的双向绑定,所以子组件接收到数据后可以直接修改,并且会同时修改父组件的数据


// Parent.vue
<template>
<child :page.sync="page"></child>
</template>
<script>
export default {
data(){
return {
page:1
}
}
}

// Child.vue
export default {
props:["page"],
computed(){
// 当我们在子组件里修改 currentPage 时,父组件的 page 也会随之改变
currentPage {
get(){
return this.page
},
set(newVal){
this.$emit("update:page", newVal)
}
}
}
}
</script>

3. v-model


和 .sync 类似,可以实现将父组件传给子组件的数据为双向绑定,子组件通过 $emit 修改父组件的数据


// Parent.vue
<template>
<child v-model="value"></child>
</template>
<script>
export default {
data(){
return {
value:1
}
}
}

// Child.vue
<template>
<input :value="value" @input="handlerChange">
</template>
export default {
props:["value"],
// 可以修改事件名,默认为 input
model:{
event:"updateValue"
},
methods:{
handlerChange(e){
this.$emit("input", e.target.value)
// 如果有上面的重命名就是这样
this.$emit("updateValue", e.target.value)
}
}
}
</script>

4. ref


ref 如果在普通的DOM元素上,引用指向的就是该DOM元素;


如果在子组件上,引用的指向就是子组件实例,然后父组件就可以通过 ref 主动获取子组件的属性或者调用子组件的方法


// Child.vue
export default {
data(){
return {
name:"沐华"
}
},
methods:{
someMethod(msg){
console.log(msg)
}
}
}

// Parent.vue
<template>
<child ref="child"></child>
</template>
<script>
export default {
mounted(){
const child = this.$refs.child
console.log(child.name) // 沐华
child.someMethod("调用了子组件的方法")
}
}
</script>

5. $emit / v-on


子组件通过派发事件的方式给父组件数据,或者触发父组件更新等操作


// Child.vue 派发
export default {
data(){
return { msg: "这是发给父组件的信息" }
},
methods: {
handleClick(){
this.$emit("sendMsg",this.msg)
}
},
}
// Parent.vue 响应
<template>
<child v-on:sendMsg="getChildMsg"></child>
// 或 简写
<child @sendMsg="getChildMsg"></child>
</template>

export default {
methods:{
getChildMsg(msg){
console.log(msg) // 这是父组件接收到的消息
}
}
}

6. $attrs / $listeners


多层嵌套组件传递数据时,如果只是传递数据,而不做中间处理的话就可以用这个,比如父组件向孙子组件传递数据时


$attrs:包含父作用域里除 class 和 style 除外的非 props 属性集合。通过 this.$attrs 获取父作用域中所有符合条件的属性集合,然后还要继续传给子组件内部的其他组件,就可以通过 v-bind="$attrs"


$listeners:包含父作用域里 .native 除外的监听事件集合。如果还要继续传给子组件内部的其他组件,就可以通过 v-on="$linteners"


使用方式是相同的


// Parent.vue
<template>
<child :name="name" title="1111" ></child>
</template
export default{
data(){
return {
name:"沐华"
}
}
}

// Child.vue
<template>
// 继续传给孙子组件
<sun-child v-bind="$attrs"></sun-child>
</template>
export default{
props:["name"], // 这里可以接收,也可以不接收
mounted(){
// 如果props接收了name 就是 { title:1111 },否则就是{ name:"沐华", title:1111 }
console.log(this.$attrs)
}
}

7. $children / $parent


$children:获取到一个包含所有子组件(不包含孙子组件)的 VueComponent 对象数组,可以直接拿到子组件中所有数据和方法等


$parent:获取到一个父节点的 VueComponent 对象,同样包含父节点中所有数据和方法等


// Parent.vue
export default{
mounted(){
this.$children[0].someMethod() // 调用第一个子组件的方法
this.$children[0].name // 获取第一个子组件中的属性
}
}

// Child.vue
export default{
mounted(){
this.$parent.someMethod() // 调用父组件的方法
this.$parent.name // 获取父组件中的属性
}
}

8. provide / inject


provide / inject 为依赖注入,说是不推荐直接用于应用程序代码中,但是在一些插件或组件库里却是被常用,所以我觉得用也没啥,还挺好用的


provide:可以让我们指定想要提供给后代组件的数据或方法


inject:在任何后代组件中接收想要添加在这个组件上的数据或方法,不管组件嵌套多深都可以直接拿来用


要注意的是 provide 和 inject 传递的数据不是响应式的,也就是说用 inject 接收来数据后,provide 里的数据改变了,后代组件中的数据不会改变,除非传入的就是一个可监听的对象


所以建议还是传递一些常量或者方法


// 父组件
export default{
// 方法一 不能获取 methods 中的方法
provide:{
name:"沐华",
age: this.data中的属性
},
// 方法二 不能获取 data 中的属性
provide(){
return {
name:"沐华",
someMethod:this.someMethod // methods 中的方法
}
},
methods:{
someMethod(){
console.log("这是注入的方法")
}
}
}

// 后代组件
export default{
inject:["name","someMethod"],
mounted(){
console.log(this.name)
this.someMethod()
}
}

9. EventBus


EventBus 是中央事件总线,不管是父子组件,兄弟组件,跨层级组件等都可以使用它完成通信操作


定义方式有三种


// 方法一
// 抽离成一个单独的 js 文件 Bus.js ,然后在需要的地方引入
// Bus.js
import Vue from "vue"
export default new Vue()

// 方法二 直接挂载到全局
// main.js
import Vue from "vue"
Vue.prototype.$bus = new Vue()

// 方法三 注入到 Vue 根对象上
// main.js
import Vue from "vue"
new Vue({
el:"#app",
data:{
Bus: new Vue()
}
})

使用如下,以方法一按需引入为例


// 在需要向外部发送自定义事件的组件内
<template>
<button @click="handlerClick">按钮</button>
</template>
import Bus from "./Bus.js"
export default{
methods:{
handlerClick(){
// 自定义事件名 sendMsg
Bus.$emit("sendMsg", "这是要向外部发送的数据")
}
}
}

// 在需要接收外部事件的组件内
import Bus from "./Bus.js"
export default{
mounted(){
// 监听事件的触发
Bus.$on("sendMsg", data => {
console.log("这是接收到的数据:", data)
})
},
beforeDestroy(){
// 取消监听
Bus.$off("sendMsg")
}
}

10. Vuex


Vuex 是状态管理器,集中式存储管理所有组件的状态。这一块内容过长,如果基础不熟的话可以看这个Vuex,然后大致用法如下


比如创建这样的文件结构


微信图片_20210824003500.jpg


index.js 里内容如下


import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
import state from './state'
import user from './modules/user'

Vue.use(Vuex)

const store = new Vuex.Store({
modules: {
user
},
getters,
actions,
mutations,
state
})
export default store

然后在 main.js 引入


import Vue from "vue"
import store from "./store"
new Vue({
el:"#app",
store,
render: h => h(App)
})

然后在需要的使用组件里


import { mapGetters, mapMutations } from "vuex"
export default{
computed:{
// 方式一 然后通过 this.属性名就可以用了
...mapGetters(["引入getters.js里属性1","属性2"])
// 方式二
...mapGetters("user", ["user模块里的属性1","属性2"])
},
methods:{
// 方式一 然后通过 this.属性名就可以用了
...mapMutations(["引入mutations.js里的方法1","方法2"])
// 方式二
...mapMutations("user",["引入user模块里的方法1","方法2"])
}
}

// 或者也可以这样获取
this.$store.state.xxx
this.$store.state.user.xxx

11. $root


$root 可以拿到 App.vue 里的数据和方法


12. slot


就是把子组件的数据通过插槽的方式传给父组件使用,然后再插回来


// Child.vue
<template>
<div>
<slot :user="user"></slot>
</div>
</template>
export default{
data(){
return {
user:{ name:"沐华" }
}
}
}

// Parent.vue
<template>
<div>
<child v-slot="slotProps">
{{ slotProps.user.name }}
</child>
</div>
</template>

结语


写作不易,你的一赞一评,就是我前行的最大动力。


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

收起阅读 »

前端9种图片格式基础知识, 你应该知道的

彩色深度 彩色深度标准通常有以下几种: 8位色,每个像素所能显示的彩色数为2的8次方,即256种颜色。 16位增强色,16位彩色,每个像素所能显示的彩色数为2的16次方,即65536种颜色。 24位真彩色,每个像素所能显示的彩色数为24位,即2的24次方,约...
继续阅读 »

彩色深度


彩色深度标准通常有以下几种:



  • 8位色,每个像素所能显示的彩色数为2的8次方,即256种颜色。

  • 16位增强色,16位彩色,每个像素所能显示的彩色数为2的16次方,即65536种颜色。

  • 24位真彩色,每个像素所能显示的彩色数为24位,即2的24次方,约1680万种颜色。

  • 32位真彩色,即在24位真彩色图像的基础上再增加一个表示图像透明度信息的Alpha通道。

    32位真彩色并非是2的32次方的色数,它其实也是1677万多色,不过它增加了256阶颜色的灰度,为了方便称呼,就规定它为32位色


图的分类


光栅图和矢量图


对于图片,一般分光栅图和矢量图。



  • 光栅图:是基于 pixel像素构成的图像。JPEG、PNG,webp等都属于此类

  • 矢量图:使用点,线和多边形等几何形状来构图,具有高分辨率和缩放功能. SVG就是一种矢量图。


无压缩, 无损压缩, 有损压缩


另一种分类




  • 无压缩。无压缩的图片格式不对图片数据进行压缩处理,能准确地呈现原图片。BMP格式就是其中之一。




  • 无损压缩。压缩算法对图片的所有的数据进行编码压缩,能在保证图片的质量的同时降低图片的尺寸。png是其中的代表。




  • 有损压缩。压缩算法不会对图片所有的数据进行编码压缩,而是在压缩的时候,去除了人眼无法识别的图片细节。因此有损压缩可以在同等图片质量的情况下大幅降低图片的尺寸。其中的代表是jpg。




前端9种图片格式


诞生时间


对于超过30岁的程序员来说,她们都很年轻,真的是遇到好时光!


85年前,人们都在干嘛呢?



  1. GIF - 1987

  2. Base64- 1987

  3. JPEG - 1992

  4. PNG - 1996

  5. SVG - 1999

  6. JPEG2000 - 1997 to 2000

  7. APNG - 2004

  8. WebP - 2010


ico: 1985年??

查阅文档说ico文件格式是伴随着 Windows 1.0 发行诞生的。


GIF


GIF是一种索引色模式图片,所以GIF每帧图所表现的颜色最多为256种。GIF能够支持动画,也能支持背景透明,这点连古老的IE6都支持,所以在以前想要在项目中使用背景透明图片,其中一种方案就是生成GIF图片。


优点



  • 支持动画和透明背景

  • 兼容性好

  • 灰度图像表现佳

  • 支持交错

    部分接收到的文件可以以较低的质量显示。这在网络连接缓慢时特别有用。


缺点



  • 最多支持 8 位 256 色,色阶过渡糟糕,图片具有颗粒感

  • 支持透明,但不支持半透明,边缘有杂边


适用场景



  • 色彩简单的logo、icon、线框图适合采用gif格

  • 动画


JPG/JPEG


这里提个问题: jpg和jpeg有啥区别


平常我们大部分见到的静态图基本都是这种图片格式。这种格式的图片能比较好的表现各种色彩,主要在压缩的时候会有所失真,也正因为如此,造就了这种图片格式体积的轻量。


优点



  • 压缩率高

  • 兼容性好

  • 色彩丰富


缺点



  • JPEG不适合用来存储企业Logo、线框类的这种高清图

  • 不支持动画、背景透明


JPEG 2000 (了解即可)


JPEG 2000是基于小波变换的图像压缩标准,由Joint Photographic Experts Group组织创建和维护。JPEG 2000通常被认为是未来取代JPEG(基于离散余弦变换)的下一代图像压缩标准。JPEG 2000文件的副档名通常为.jp2,MIME类型是image/jp2。


JPEG2000的压缩比更高,而且不会产生原先的基于离散余弦变换的JPEG标准产生的块状模糊瑕疵。JPEG2000同时支持有损压缩无损压缩


目前就safari支持,can is use-png2000支持18%。


优点



  • 支持有损和无损压缩


缺点



  • 支持率太低了


ICO


ICO (Microsoft Windows 图标)文件格式是微软为 Windows 系统的桌面图标设计的。网站可以在网站的根目录中提供一个名为 favicon.ICO, 在收藏夹菜单中显示的图标,以及其他一些有用的标志性网站表示形式。

一个 ICO 文件可以包含多个图标,并以列出每个图标详细信息的目录开始。


其主要用来做网站图标,现在png也是可以用来做网站图标的。


PNG


PNG格式是有三种版本的,分别为PNG-8,PNG-24,PNG-32,所有这些版本都不支持动画的。PNG-8跟GIF类似的属性是相似的,都是索引色模式,而且都支持背景透明。相对比GIF格式好的特点在与背景透明时,图像边缘没有什么噪点,颜色表现更优秀。PNG-24其实就是无损压缩的JPEG。而PNG-32就是在PNG-24的基础上,增加了透明度的支持。


如果没有动画需求推荐使用png-8来替代gif


优点



  1. 不失真的情况下尽可能压缩图像文件的大小

  2. 像素丰富

  3. 支持透明(alpha通道)


缺点



  1. 文件大


这里额外提一下,gif和jpg有渐进,png有交错,都是在没有完全下载图片的时候,能看到图片全貌。


具体可以看在线示例: png正常,png交错,jpg渐进


APNG:Animated PNG


APNG(Animated Portable Network Graphics)顾名思义是基于 PNG 格式扩展的一种动画格式,增加了对动画图像的支持,同时加入了 24 位图像和 8 位 Alpha 透明度的支持,这意味着动画将拥有更好的质量,其诞生的目的是为了替代老旧的 GIF 格式,但它目前并没有获得 PNG 组织官方的认可。


从Can I Use上查看,除了IE系列, chrome, firefox, safari均已支持。2021-08月的时候支持达到94%。


相对GIF来说



  • 色彩丰富

  • 支持透明

  • 向下兼容 PNG

  • 支持动画


缺点



  • 生成比较繁琐

  • 未标准化


webP


有损 WebP 图像平均比视觉上类似压缩级别的 JPEG 图像小25-35% 。无损耗的 WebP 图像通常比 PNG 格式的相同图像小26% 。WebP 还支持动画: 在有损的 WebP 文件中,图像数据由 VP8位流表示,该位流可能包含多个帧。


包括体积小、色彩表现足够、支持动画。 简直了就是心中的完美女神!!


can i use - webp上看,支持率95%。 主要是Safari低版本和IE低版本不兼容。


优点



  • 同等质量更小

  • 压缩之后质量无明显变化

  • 支持无损图像

  • 支持动画


缺点



  • 兼容性吧,相对jpg,png,gif来说


SVG


SVG 是一种基于 xml 的矢量图形格式,它将图像的内容指定为一组绘图命令,这些命令创建形状、线条、应用颜色、过滤器等等。SVG 文件是理想的图表,图标和其他图像,可以准确地绘制在任何大小。因此,SVG 是现代 Web 设计中用户界面元素的流行选择。


优点



  • 可伸缩性

    你可以随心所欲地把它们做大或者做小,而不用牺牲质量



  • Svg 平均比 GIF、 JPEG、 PNG 小得多,甚至在极高的分辨率下也是如此

  • 支持动画

    更灵活,质量无与伦比

  • 与DOM无缝衔接

    Svg 可以直接使用 HTML、 CSS 和 JavaScript (例如动画)来操作


缺点



  • SVG复杂度高会减慢渲染速度

  • 不适合游戏类等高互动动画


base64


图片的 base64 编码就是可以将一副图片数据编码成一串字符串,使用该字符串代替图像地址,图片随着 HTML 的下载同时下载到本地,不再单独消耗一个http来请求图片。


优点



  • 无额外请求

  • 对于极小或者极简单图片

  • 可像单独图片一样使用,比如背景图片重复使用等

  • 没有跨域问题,无需考虑缓存、文件头或者cookies问题  


缺点



  • 相比其他格式,体积会至少大1/3

  • 编码解码有额外消耗


一些对比


PNG, GIF, JPG 比较


大小比较:通常地,PNG ≈ JPG > GIF 8位的PNG完全可以替代掉GIF

透明性:PNG > GIF > JPG

色彩丰富程度:JPG > PNG >GIF

兼容程度:GIF ≈ JPG > PNG

gif, jpg, png, web优缺点和使用场景



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

收起阅读 »

(算法入门)人人都能看懂的时间复杂度和空间复杂度

你是怎么理解算法的呢? 简单说就是,同一个功能 别人写的代码跑起来占内存 100M,耗时 100 毫秒 你写的代码跑起来占内存 500M,耗时 1000 毫秒,甚至更多 所以 衡量代码好坏有两个非常重要的标准就是:运行时间和占用空间,就是我们后面要说到的...
继续阅读 »

你是怎么理解算法的呢?


简单说就是,同一个功能



  • 别人写的代码跑起来占内存 100M,耗时 100 毫秒

  • 你写的代码跑起来占内存 500M,耗时 1000 毫秒,甚至更多


所以



  1. 衡量代码好坏有两个非常重要的标准就是:运行时间占用空间,就是我们后面要说到的时间复杂度空间复杂度也是学好算法的重要基石

  2. 这也是会算法和不会算法的攻城狮的区别、更是薪资的区别,因为待遇好的大厂面试基本都有算法


可能有人会问:别人是怎么做到的?代码没开发完 运行起来之前怎么知道占多少内存和运行时间呢?


确切的占内用存或运行时间确实算不出来,而且同一段代码在不同性能的机器上执行的时间也不一样,可是代码的基本执行次数,我们是可以算得出来的,这就要说到时间复杂度了


什么是时间复杂度


看个栗子


function foo1(){
console.log("我吃了一颗糖")
console.log("我又吃了一颗糖")
return "再吃一颗糖"
}

调用这个函数,里面总执行次数就是3次,这个没毛病,都不用算


那么下面这个栗子呢


function foo2(n){
for( let i = 0; i < n; i++){
console.log("我吃了一颗糖")
}
return "一颗糖"
}

那这个函数里面总执行次数呢?根据我们传进去的值不一样,执行次数也就不一样,但是大概次数我们总能知道


let = 0               :执行 1 次
i < n : 执行 n+1 次
i++ : 执行 n+1 次
console.log("执行了") : 执行 n 次
return 1 : 执行 1 次

这个函数的总执行次数就是 3n + 4 次,对吧


可是我们开发不可能都这样去数,所以根据代码执行时间的推导过程就有一个规律,也就是所有代码执行时间 T(n)和代码的执行次数 f(n) ,这个是成正比的,而这个规律有一个公式



T(n) = O( f(n) )



n 是输入数据的大小或者输入数据的数量  
T(n) 表示一段代码的总执行时间
f(n) 表示一段代码的总执行次数
O 表示代码的执行时间 T(n) 和 执行次数f(n) 成正比

完整的公式看着就很麻烦,别着急,这个公式只要了解一下就可以了,为的就是让你知道我们表示算法复杂度的 O() 是怎么来的,我们平时表示算法复杂度主要就是用 O(),读作大欧表示法,是字母O不是零


只用一个 O() 表示,这样看起来立马就容易理解多了


回到刚才的两个例子,就是上面的两个函数



  • 第一个函数执行了3次,用复杂度表示就是 O(3)

  • 第二个函数执行了3n + 4次,复杂度就是 O(3n+4)


这样有没有觉得还是很麻烦,因为如果函数逻辑一样的,只是执行次数差个几次,像O(3) 和 O(4),有什么差别?还要写成两种就有点多此一举了,所以复杂度里有统一的简化的表示法,这个执行时间简化的估算值就是我们最终的时间复杂度


简化的过程如下



  • 如果只是常数直接估算为1,O(3) 的时间复杂度就是 O(1),不是说只执行了1次,而是对常量级时间复杂度的一种表示法。一般情况下,只要算法里没有循环和递归,就算有上万行代码,时间复杂度也是O(1)

  • O(3n+4) 里常数4对于总执行次数的几乎没有影响,直接忽略不计,系数 3 影响也不大,因为3n和n都是一个量级的,所以作为系数的常数3也估算为1或者可以理解为去掉系数,所以 O(3n+4) 的时间复杂度为 O(n)

  • 如果是多项式,只需要保留n的最高次项O( 666n³ + 666n² + n ),这个复杂度里面的最高次项是n的3次方。因为随着n的增大,后面的项的增长远远不及n的最高次项大,所以低于这个次项的直接忽略不计,常数也忽略不计,简化后的时间复杂度为 O(n³)


这里如果没有理解的话,暂停理解一下


接下来结合栗子,看一下常见的时间复杂度


常用时间复杂度


O(1)


上面说了,一般情况下,只要算法里没有循环和递归,就算有上万行代码,时间复杂度也是 O(1),因为它的执行次数不会随着任何一个变量的增大而变长,比如下面这样


function foo(){
let n = 1
let b = n * 100
if(b === 100){
console.log("开始吃糖")
}
console.log("我吃了1颗糖")
console.log("我吃了2颗糖")
......
console.log("我吃了10000颗糖")
}

O(n)


上面也介绍了 O(n),总的来说 只有一层循环或者递归等,时间复杂度就是 O(n),比如下面这样


function foo1(n){
for( let i = 0; i < n; i++){
console.log("我吃了一颗糖")
}
}
function foo2(n){
while( --n > 0){
console.log("我吃了一颗糖")
}
}
function foo3(n){
console.log("我吃了一颗糖")
--n > 0 && foo3(n)
}

O(n²)


比如嵌套循环,如下面这样的,里层循环执行 n 次,外层循环也执行 n 次,总执行次数就是 n x n,时间复杂度就是 n 的平方,也就是 O(n²)。假设 n 是 10,那么里面的就会打印 10 x 10 = 100 次


function foo1(n){
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}

还有这样的,总执行次数为 n + n²,上面说了,如果是多项式,取最高次项,所以这个时间复杂度也是 O(n²)


function foo2(n){
for( let k = 0; k < n; k++){
console.log("我吃了一颗糖")
}
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}

//或者下面这样,以运行时间最长的,作为时间复杂度的依据,所以下面的时间复杂度就是 O(n²)
function foo3(n){
if( n > 100){
for( let k = 0; k < n; k++){
console.log("我吃了一颗糖")
}
}else{
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}
}

O(logn)


举个栗子,这里有一包糖


asdf.jpeg


这包糖里有16颗,沐华每天吃这一包糖的一半,请问多少天吃完?


意思就是16不断除以2,除几次之后等于1?用代码表示


function foo1(n){
let day = 0
while(n > 1){
n = n/2
day++
}
return day
}
console.log( foo1(16) ) // 4

循环次数的影响主要来源于 n/2 ,这个时间复杂度就是 O(logn) ,这个复杂度是怎么来的呢,别着急,继续看


再比如下面这样


function foo2(n){
for(let i = 0; i < n; i *= 2){
console.log("一天")
}
}
foo2( 16 )

里面的打印执行了 4 次,循环次数主要影响来源于 i *= 2 ,这个时间复杂度也是 O(logn)


这个 O(logn) 是怎么来的,这里补充一个小学三年级数学的知识点,对数,我们看一张图


未标题-1.jpg


没有理解的话再看一下,理解一下规律



  • 真数:就是真数,这道题里就是16

  • 底数:就是值变化的规律,比如每次循环都是i*=2,这个乘以2就是规律。比如1,2,3,4,5...这样的值的话,底就是1,每个数变化的规律是+1嘛

  • 对数:在这道题里可以理解成x2乘了多少次,这个次数


仔细观察规律就会发现这道题里底数是 2,而我们要求的天数就是这个对数4,在对数里有一个表达公式



ab = n  读作以a为底,b的对数=n,在这道题里我们知道a和n的值,也就是  2b = 16 然后求 b



把这个公式转换一下的写法如下



logan = b    在这道题里就是   log216 = ?  答案就是 4



公式是固定的,这个16不是固定的,是我们传进去的 n,所以可以理解为这道题就是求 log2n = ?


用时间复杂度表示就是 O(log2n),由于时间复杂度需要去掉常数和系数,而log的底数跟系数是一样的,所以也需要去掉,所以最后这个正确的时间复杂度就是 O(logn)


emmmmm.....


没有理解的话,可以暂停理解一下


其他还有一些时间复杂度,我由快到慢排列了一下,如下表顺序



这些时间复杂度有什么区别呢,看张图


未标题-3.jpg


随着数据量或者 n 的增大,时间复杂度也随之增加,也就是执行时间的增加,会越来越慢,越来越卡


总的来说时间复杂度就是执行时间增长的趋势,那么空间复杂度就是存储空间增长的趋势


什么是空间复杂度


空间复杂度就是算法需要多少内存,占用了多少空间


常用的空间复杂度有 O(1)O(n)O(n²)


O(1)


只要不会因为算法里的执行,导致额外的空间增长,就算是一万行,空间复杂度也是 O(1),比如下面这样,时间复杂度也是 O(1)


function foo(){
let n = 1
let b = n * 100
if(b === 100){
console.log("开始吃糖")
}
console.log("我吃了1颗糖")
console.log("我吃了2颗糖")
......
console.log("我吃了10000颗糖")
}

O(n)


比如下面这样,n 的数值越大,算法需要分配的空间就需要越多,来存储数组里的值,所以它的空间复杂度就是 O(n),时间复杂度也是 O(n)


function foo(n){
let arr = []
for( let i = 1; i < n; i++ ) {
arr[i] = i
}
}

O(n²)


O(n²) 这种空间复杂度一般出现在比如二维数组,或是矩阵的情况下


不用说,你肯定明白是啥情况啦


就是遍历生成类似这样格式的


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

结语


希望本文对你有一点点帮助,另外,求个赞,谢谢! ^_^


想要学好算法,就必须要理解复杂度这个重要基石


复杂度分析不难,关键还是在于多练。每次看到代码的时候,简单的一眼就能看出复杂度,难的稍微分析一下也能得出答案。推荐去 leetCode 刷题哦,App或者PC端都可以


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

收起阅读 »

什么?数学不好人都不配写CSS?

前言 大家好,这里是 CSS 兼 WebGL 魔法使——alphardex。 之前一直在玩 three.js ,接触了很多数学函数,用它们创造过很多特效。于是我思考:能否在 CSS 中也用上这些数学函数,但发现 CSS 目前还没有,据说以后的新规范会纳入,估计...
继续阅读 »

前言


大家好,这里是 CSS 兼 WebGL 魔法使——alphardex。


之前一直在玩 three.js ,接触了很多数学函数,用它们创造过很多特效。于是我思考:能否在 CSS 中也用上这些数学函数,但发现 CSS 目前还没有,据说以后的新规范会纳入,估计也要等很久。


然而,我们可以通过一些小技巧,来创作出一些属于自己的 CSS 数学函数,从而实现一些有趣的动画效果。


让我们开始吧!



CSS 数学函数


注意:以下的函数用原生 CSS 也都能实现,这里用 SCSS 函数只是为了方便封装,封装起来的话更方便调用


绝对值


绝对值就是正的还是正的,负的变为正的


可以创造 2 个数,其中一个数是另一个数的相反数,比较它们的最大值,即可获得这个数的绝对值


@function abs($v) {
@return max(#{$v}, calc(-1 * #{$v}));
}

中位数


原数减 1 并乘以一半即可


@function middle($v) {
@return calc(0.5 * (#{$v} - 1));
}

数轴上两点距离


数轴上两点距离就是两点所表示数字之差的绝对值,有了上面的绝对值公式就可以直接写出来


@function dist-1d($v1, $v2) {
$v-delta: calc(#{$v1} - #{$v2});
@return #{abs($v-delta)};
}

三角函数


其实这个笔者也不会实现~不过之前看到过好友 chokcoco 的一篇文章写到了如何在 CSS 中实现三角函数,在此表示感谢


@function fact($number) {
$value: 1;
@if $number>0 {
@for $i from 1 through $number {
$value: $value * $i;
}
}
@return $value;
}

@function pow($number, $exp) {
$value: 1;
@if $exp>0 {
@for $i from 1 through $exp {
$value: $value * $number;
}
} @else if $exp < 0 {
@for $i from 1 through -$exp {
$value: $value / $number;
}
}
@return $value;
}

@function rad($angle) {
$unit: unit($angle);
$unitless: $angle / ($angle * 0 + 1);
@if $unit==deg {
$unitless: $unitless / 180 * pi();
}
@return $unitless;
}

@function pi() {
@return 3.14159265359;
}

@function sin($angle) {
$sin: 0;
$angle: rad($angle);
// Iterate a bunch of times.
@for $i from 0 through 20 {
$sin: $sin + pow(-1, $i) * pow($angle, (2 * $i + 1)) / fact(2 * $i + 1);
}
@return $sin;
}

@function cos($angle) {
$cos: 0;
$angle: rad($angle);
// Iterate a bunch of times.
@for $i from 0 through 20 {
$cos: $cos + pow(-1, $i) * pow($angle, 2 * $i) / fact(2 * $i);
}
@return $cos;
}

@function tan($angle) {
@return sin($angle) / cos($angle);
}

例子


以下的几个动画特效演示了上面数学函数的作用


一维交错动画


初始状态


创建一排元素,用内部阴影填充,准备好我们的数学函数


<div class="list">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #222;
}

:root {
--blue-color-1: #6ee1f5;
}

(这里复制粘贴上文所有的数学公式)

.list {
--n: 16;

display: flex;
flex-wrap: wrap;
justify-content: space-evenly;

&-item {
--p: 2vw;
--gap: 1vw;
--bg: var(--blue-color-1);

@for $i from 1 through 16 {
&:nth-child(#{$i}) {
--i: #{$i};
}
}

padding: var(--p);
margin: var(--gap);
box-shadow: inset 0 0 0 var(--p) var(--bg);
}
}

fb7wZV.png


应用动画


这里用了 2 个动画:grow 负责将元素缩放出来;melt 负责“融化”元素(即消除阴影的扩散半径)


<div class="list grow-melt">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

.list {
&.grow-melt {
.list-item {
--t: 2s;

animation-name: grow, melt;
animation-duration: var(--t);
animation-iteration-count: infinite;
}
}
}

@keyframes grow {
0% {
transform: scale(0);
}

50%,
100% {
transform: scale(1);
}
}

@keyframes melt {
0%,
50% {
box-shadow: inset 0 0 0 var(--p) var(--bg);
}

100% {
box-shadow: inset 0 0 0 0 var(--bg);
}
}

fqkIkF.gif


交错动画



  1. 计算出元素下标的中位数

  2. 计算每个元素 id 到这个中位数的距离

  3. 根据距离算出比例

  4. 根据比例算出 delay


<div class="list grow-melt middle-stagger">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

.list {
&.middle-stagger {
.list-item {
--m: #{middle(var(--n))}; // 中位数,这里是7.5
--i-m-dist: #{dist-1d(var(--i), var(--m))}; // 计算每个id到中位数之间的距离
--ratio: calc(var(--i-m-dist) / var(--m)); // 根据距离算出比例
--delay: calc(var(--ratio) * var(--t)); // 根据比例算出delay
--n-delay: calc((var(--ratio) - 2) * var(--t)); // 负delay表示动画提前开始

animation-delay: var(--n-delay);
}
}
}

fqkzkD.gif


地址:Symmetric Line Animation


二维交错动画


初始状态


如何将一维的升成二维?应用网格系统即可


<div class="grid">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
$row: 8;
$col: 8;
--row: #{$row};
--col: #{$col};
--gap: 0.25vw;

display: grid;
gap: var(--gap);
grid-template-rows: repeat(var(--row), 1fr);
grid-template-columns: repeat(var(--col), 1fr);

&-item {
--p: 2vw;
--bg: var(--blue-color-1);

@for $y from 1 through $row {
@for $x from 1 through $col {
$k: $col * ($y - 1) + $x;
&:nth-child(#{$k}) {
--x: #{$x};
--y: #{$y};
}
}
}

padding: var(--p);
box-shadow: inset 0 0 0 var(--p) var(--bg);
}
}

fLsvPx.png


应用动画


跟上面的动画一模一样


<div class="grid grow-melt">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
&.grow-melt {
.grid-item {
--t: 2s;

animation-name: grow, melt;
animation-duration: var(--t);
animation-iteration-count: infinite;
}
}
}

fLsGvD.gif


交错动画



  1. 计算出网格行列的中位数

  2. 计算网格 xy 坐标到中位数的距离并求和

  3. 根据距离算出比例

  4. 根据比例算出 delay


<div class="grid grow-melt middle-stagger">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
&.middle-stagger {
.grid-item {
--m: #{middle(var(--col))}; // 中位数,这里是7.5
--x-m-dist: #{dist-1d(var(--x), var(--m))}; // 计算x坐标到中位数之间的距离
--y-m-dist: #{dist-1d(var(--y), var(--m))}; // 计算y坐标到中位数之间的距离
--dist-sum: calc(var(--x-m-dist) + var(--y-m-dist)); // 距离之和
--ratio: calc(var(--dist-sum) / var(--m)); // 根据距离和计算比例
--delay: calc(var(--ratio) * var(--t) * 0.5); // 根据比例算出delay
--n-delay: calc(
(var(--ratio) - 2) * var(--t) * 0.5
); // 负delay表示动画提前开始

animation-delay: var(--n-delay);
}
}
}

fL2Ppt.gif


地址:Symmetric Grid Animation


另一种动画


可以换一种动画 shuffle(穿梭),会产生另一种奇特的效果


<div class="grid shuffle middle-stagger">
<div class="grid-item"></div>
...(此处省略254个 grid-item )
<div class="grid-item"></div>
</div>

.grid {
$row: 16;
$col: 16;
--row: #{$row};
--col: #{$col};
--gap: 0.25vw;

&-item {
--p: 1vw;

transform-origin: bottom;
transform: scaleY(0.1);
}

&.shuffle {
.grid-item {
--t: 2s;

animation: shuffle var(--t) infinite ease-in-out alternate;
}
}
}

@keyframes shuffle {
0% {
transform: scaleY(0.1);
}

50% {
transform: scaleY(1);
transform-origin: bottom;
}

50.01% {
transform-origin: top;
}

100% {
transform-origin: top;
transform: scaleY(0.1);
}
}

fOJSZ8.gif


地址:Shuffle Grid Animation


余弦波动动画


初始状态


创建 7 个不同颜色的(这里直接选了彩虹色)列表,每个列表有 40 个子元素,每个子元素是一个小圆点


让这 7 个列表排列在一条线上,且 z 轴上距离错开,设置好基本的 delay


<div class="lists">
<div class="list">
<div class="list-item"></div>
...(此处省略39个 list-item)
</div>
...(此处省略6个 list)
</div>

.lists {
$list-count: 7;
$colors: red, orange, yellow, green, cyan, blue, purple;

position: relative;
width: 34vw;
height: 2vw;
transform-style: preserve-3d;
perspective: 800px;

.list {
position: absolute;
top: 0;
left: 0;
display: flex;
transform: translateZ(var(--z));

@for $i from 1 through $list-count {
&:nth-child(#{$i}) {
--bg: #{nth($colors, $i)};
--z: #{$i * -1vw};
--basic-delay-ratio: #{$i / $list-count};
}
}

&-item {
--w: 0.6vw;
--gap: 0.15vw;

width: var(--w);
height: var(--w);
margin: var(--gap);
background: var(--bg);
border-radius: 50%;
}
}
}

hSdtfI.png


余弦排列


运用上文的三角函数公式,让这些小圆点以余弦的一部分形状进行排列


.lists {
.list {
&-item {
$item-count: 40;
$offset: pi() * 0.5;
--wave-length: 21vw;

@for $i from 1 through $item-count {
&:nth-child(#{$i}) {
--i: #{$i};
$ratio: ($i - 1) / ($item-count - 1);
$angle-unit: pi() * $ratio;
$wave: cos($angle-unit + $offset);
--single-wave-length: calc(#{$wave} * var(--wave-length));
--n-single-wave-length: calc(var(--single-wave-length) * -1);
}
}

transform: translateY(var(--n-single-wave-length));
}
}
}

hSwuNj.png


波动动画


对每个小圆点应用上下平移动画,平移的距离就是余弦的波动距离


.lists {
.list {
&-item {
--t: 2s;

animation: wave var(--t) infinite ease-in-out alternate;
}
}
}

@keyframes wave {
from {
transform: translateY(var(--n-single-wave-length));
}

to {
transform: translateY(var(--single-wave-length));
}
}

hSwfPA.gif


交错动画


跟上面一个套路,计算从中间开始的 delay,再应用到动画上即可


.lists {
.list {
&-item {
--n: #{$item-count + 1};
--m: #{middle(var(--n))};
--i-m-dist: #{dist-1d(var(--i), var(--m))};
--ratio: calc(var(--i-m-dist) / var(--m));
--square: calc(var(--ratio) * var(--ratio));
--delay: calc(
calc(var(--square) + var(--basic-delay-ratio) + 1) * var(--t)
);
--n-delay: calc(var(--delay) * -1);

animation-delay: var(--n-delay);
}
}
}

hSwqaQ.gif


地址:Rainbow Sine


最后


CSS 数学函数能实现的特效远不止于此,希望通过本文能激起大家创作特效的灵感~


作者:alphardex
链接:https://juejin.cn/post/6999416290997698596

收起阅读 »

聊一聊移动端适配

一、引言 用户选择大屏幕有两个几个出发点,有些人想要更大的字体,更大的图片;有些人想要更多的内容,并不想要更大的图标;有些人想要个镜子…. 充分了解各种设备,我们会知道不同尺寸的屏幕本身就有各自的定位,像ipad类的大屏设备本身相比较iphone5就应该具...
继续阅读 »

一、引言



用户选择大屏幕有两个几个出发点,有些人想要更大的字体,更大的图片;有些人想要更多的内容,并不想要更大的图标;有些人想要个镜子….



充分了解各种设备,我们会知道不同尺寸的屏幕本身就有各自的定位,像ipad类的大屏设备本身相比较iphone5就应该具有更大的视野,而不是粗暴的让用户去感受老人机的体验。


但由于设计及开发资源的紧张,现阶段只能将一套设计稿应用在多尺寸设备上,因此我们需要考虑在保持一套设计稿的方案下如何使展示更加合理。


二、基本单位


对于移动端开发而言,为了做到页面高清的效果,视觉稿的规范往往会遵循以下两点:



  1. 首先,选取一款手机的屏幕宽高作为基准(以前是iphone4的320×480,现在更多的是iphone6的375×667)。

  2. 对于retina屏幕(如: dpr=2),为了达到高清效果,视觉稿的画布大小会是基准的2倍,也就是说像素点个数是原来的4倍(对iphone6而言:原先的375×667,就会变成750×1334)。


物理像素(physical pixel)


一个物理像素是显示器(手机屏幕)上最小的物理显示单元,在操作系统的调度下,每一个设备像素都有自己的颜色值和亮度值。


设备独立像素(density-independent pixel)


设备独立像素(也叫密度无关像素),可以认为是计算机坐标系统中得一个点,这个点代表一个可以由程序使用的虚拟像素(比如: css像素),然后由相关系统转换为物理像素。


所以说,物理像素和设备独立像素之间存在着一定的对应关系,这就是接下来要说的设备像素比。


DPR 设备像素比(device pixel ratio )


设备像素比 = 物理像素 / 设备独立像素; // 在某一方向上,x方向或者y方向
可以在JS中 window.devicePixelRatio获取到当前设备的dpr


三、常见的布局类型


rem 布局


原理: 根据手机的屏幕尺寸 和dpr,动态修改html的基准值(font-size)


公式: rem = document.documentElement.clientWidth * dpr / 100


注释: 乘以dpr,是因为页面有可能为了实现1px border页面会缩放(scale) 1/dpr 倍(如果没有,dpr=1)


假设我们将屏幕宽度平均分成100份,每一份的宽度用per表示,per = 屏幕宽度 / 100,如果将per作为单位,per前面的数值就代表屏幕宽度的百分比


p {width: 50per;} /* 屏幕宽度的50% */

如果想要页面元素随着屏幕宽度等比变化,我们需要上面的per单位,如果子元素设置rem单位的属性,通过更改html元素的字体大小,就可以让子元素实际大小发生变化


html {font-size: 16px}
p {width: 2rem} /* 32px*/

html {font-size: 32px}
p {width: 2rem} /*64px*/

如果让html元素字体的大小,恒等于屏幕宽度的1/100,那1rem和1per就等价了


html {fons-size: 元素宽度 / 100}
p {width: 50rem} /* 50rem = 50per = 屏幕宽度的50% */

实际应用



rem作用于非根元素时,相对于根元素字体大小;rem作用于根元素字体大小时,相对于其初始字体大小



可以看出 rem 取值分为两种情况,设置在根元素时和非根元素时,举个例子:


/* 作用于根元素,相对于原始大小(16px),所以html的font-size为32px*/
html {font-size: 2rem}

/* 作用于非根元素,相对于根元素字体大小,所以为64px */
p {font-size: 2rem}

举个例子:
























vw


vw/vh是基于 Viewport 视窗的长度单位window.innerWidth/window.innerHeight
在CSS Values and Units Module Level 3中和Viewport相关的单位有四个,分别为vwvhvminvmax



  • vw:是Viewport’s width的简写, 1vw等于window.innerWidth的1%

  • vh:和vw类似,是Viewport’s height的简写,1vh等于window.innerHeihgt的1%\

  • vmin:vmin的值是当前vw和vh中较小的值

  • vmax:vmax的值是当前vw和vh中较大的值


image.png
可以看到vw其实是实现了1vw = 1per,比起rem需要计算html的基准值,vw无疑更加方便。


/* rem方案 */
html {fons-size: width / 100}
p {width: 15.625rem}

/* vw方案 */
p {width: 15.625vw}

Q:vw如此方便,是不是就比rem更好,可以完全取代rem了呢?


A:当然不是。


vw也有缺点。



  • vw换算有时并不精确,较小的像素不好适配,就像我们可以用较小值精确地表示较大值,用较大值表示较小值就可能存在数位换算等问题而无法精确表示。

  • vw的兼容性不如rem

  • 使用弹性布局时,vw无法限制最大宽度。rem可以通过控制HTML基准值,来实现最大宽度的限制。


Q:rem就如此完美吗?


A:rem也并不是万能的



  • rem的制作成本更大,需要使用额外的插件去实现。

  • 字体不能用rem,字体大小和字体宽度不成线性关系,所有字体大小不能使用rem,由于设置了根元素字体的大小,会影响所有没有设置字体的元素,因此需要设置所有需要字体控制的元素。

  • 从用户体验上来看,文字阅读的舒适度跟媒体介质大小是没关系的。


四、适配方案


方案一: rem/vw


适用场景:



  • 对视觉组件种类较多,视觉设计对元素位置的相对关系依赖较强的移动端页面:vw/rem


示例:



  • 饿了么(h5.ele.me/msite/)

  • 对viewport进行了缩放

  • html元素的font-size依然由px指定

  • 具体元素的布局上使用vw + rem fallbak的形式

  • 没有限制布局宽度

  • css构建过程需要插件支持


方案二: flex + px + 百分比


适用场景:



  • 追求阅读体验的场景,如列表页。


示例:





作者:_Battle
链接:https://juejin.cn/post/6999438892441026591

收起阅读 »

8个工程必备的JavaScript代码片段(建议添加到项目中)

1. 获取文件后缀名 使用场景:上传文件判断后缀名 /** * 获取文件后缀名 * @param {String} filename */ export function getExt(filename) { if (typeof filena...
继续阅读 »

1. 获取文件后缀名


使用场景:上传文件判断后缀名


/**
* 获取文件后缀名
* @param {String} filename
*/
export function getExt(filename) {
if (typeof filename == 'string') {
return filename
.split('.')
.pop()
.toLowerCase()
} else {
throw new Error('filename must be a string type')
}
}

使用方式


getExt("1.mp4") //->mp4

2. 复制内容到剪贴板


export function copyToBoard(value) {
const element = document.createElement('textarea')
document.body.appendChild(element)
element.value = value
element.select()
if (document.execCommand('copy')) {
document.execCommand('copy')
document.body.removeChild(element)
return true
}
document.body.removeChild(element)
return false
}


使用方式:


//如果复制成功返回true
copyToBoard('lalallala')

原理:



  1. 创建一个textare元素并调用select()方法选中

  2. document.execCommand('copy')方法,拷贝当前选中内容到剪贴板。


3. 休眠多少毫秒


/**
* 休眠xxxms
* @param {Number} milliseconds
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

//使用方式
const fetchData=async()=>{
await sleep(1000)
}

4. 生成随机字符串


/**
* 生成随机id
* @param {*} length
* @param {*} chars
*/
export function uuid(length, chars) {
chars =
chars ||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
length = length || 8
var result = ''
for (var i = length; i > 0; --i)
result += chars[Math.floor(Math.random() * chars.length)]
return result
}

使用方式


//第一个参数指定位数,第二个字符串指定字符,都是可选参数,如果都不传,默认生成8位
uuid()

使用场景:用于前端生成随机的ID,毕竟现在的Vue和React都需要绑定key


5. 简单的深拷贝


/**
*深拷贝
* @export
* @param {*} obj
* @returns
*/
export function deepCopy(obj) {
if (typeof obj != 'object') {
return obj
}
if (obj == null) {
return obj
}
return JSON.parse(JSON.stringify(obj))
}

缺陷:只拷贝对象、数组以及对象数组,对于大部分场景已经足够


const person={name:'xiaoming',child:{name:'Jack'}}
deepCopy(person) //new person

6. 数组去重


/**
* 数组去重
* @param {*} arr
*/
export function uniqueArray(arr) {
if (!Array.isArray(arr)) {
throw new Error('The first parameter must be an array')
}
if (arr.length == 1) {
return arr
}
return [...new Set(arr)]
}

原理是利用Set中不能出现重复元素的特性


uniqueArray([1,1,1,1,1])//[1]

7. 对象转化为FormData对象


/**
* 对象转化为formdata
* @param {Object} object
*/

export function getFormData(object) {
const formData = new FormData()
Object.keys(object).forEach(key => {
const value = object[key]
if (Array.isArray(value)) {
value.forEach((subValue, i) =>
formData.append(key + `[${i}]`, subValue)
)
} else {
formData.append(key, object[key])
}
})
return formData
}

使用场景:上传文件时我们要新建一个FormData对象,然后有多少个参数就append多少次,使用该函数可以简化逻辑


使用方式:


let req={
file:xxx,
userId:1,
phone:'15198763636',
//...
}
fetch(getFormData(req))

8.保留到小数点以后n位


// 保留小数点以后几位,默认2位
export function cutNumber(number, no = 2) {
if (typeof number != 'number') {
number = Number(number)
}
return Number(number.toFixed(no))
}

使用场景:JS的浮点数超长,有时候页面显示时需要保留2位小数


作者:_红领巾
链接:https://juejin.cn/post/6999391770672889893

收起阅读 »

前端工程化实战 - 可配置的模板管理

功能设计 如果每一次新脚手架的开发或者模板的更新都需要重新更新一次 CLI 的话,虽然成本不高,但是开发模板的同学需要通知 CLI 开发的同学去升级,使用模板的同学又需要在等 CLI 开发完毕才能使用,中间交流沟通的成本就增加了。 其次,对于业务开发同学来说,...
继续阅读 »

功能设计


如果每一次新脚手架的开发或者模板的更新都需要重新更新一次 CLI 的话,虽然成本不高,但是开发模板的同学需要通知 CLI 开发的同学去升级,使用模板的同学又需要在等 CLI 开发完毕才能使用,中间交流沟通的成本就增加了。


其次,对于业务开发同学来说,可能只需要一类或者几类的模板,那么如果 CLI 是一个大而全的模板集合,对这些同学来说,快速选择模板创建项目反而也是一个负担,因为要在很多模板中选择自己想要的也是很花费时间。


所以我们的目的是设计一款拥有自定义配置与可升级模板功能的 CLI 工具。


未命名文件.png


既然是自定义配置,那么就需要用户可以在本地手动添加、删除、更新自己常用的模板信息,同时需要可以动态的拉取这些模板而不是一直下载下来就是本地的旧版本。


根据需求,可简单设计一下我们 CLI 的模板功能概要:



  1. 需要保存模板来源的地址

  2. 根据用户的选择拉取不同的模板代码

  3. 将模板保存在本地


实战开发


那么根据上面的设计思路,我们可以一步步开发所需要的功能


本地保存模板地址功能


第一步,如果需要将模板的一些信息保存在本地的话,我们需要一个对话型的交互,引导用户输入我们需要的信息,所以可以选择 inquirer 这个工具库。



Inquirerjs 是一个用来实现命令行交互式界面的工具集合。它帮助我们实现与用户的交互式交流,比如给用户提一个问题,用户给我们一个答案,我们根据用户的答案来做一些事情,典型应用如 plop等生成器工具。



一般拉取代码的话,我们需要知道用户输入的模板地址(通过 URL 拉取对应模板的必须条件)、模板别名(方便用户做搜索)、模板描述(方便用户了解模板信息)


这样需要保存的模板信息有地址、别名与描述,后续可以方便我们去管理对应的模板。示例代码如下:


import inquirer from 'inquirer';
import { addTpl } from '@/tpl'

const promptList = [
{
type: 'input',
message: '请输入仓库地址:',
name: 'tplUrl',
default: 'https://github.com/boty-design/react-tpl'
},
{
type: 'input',
message: '模板标题(默认为 Git 名作为标题):',
name: 'name',
default({ tplUrl }: { tplUrl: string }) {
return tplUrl.substring(tplUrl.lastIndexOf('/') + 1)
}
},
{
type: 'input',
message: '描述:',
name: 'desc',
}
];

export default () => {
inquirer.prompt(promptList).then((answers: any) => {
const { tplUrl, name, desc } = answers
addTpl(tplUrl, name, desc)
})
}
复制代码

通过 inquirer 已经拿到了对应的信息,但由于会有电脑重启等各种情况发生,所以数据存在缓存中是不方便的,这种 CLI 工具如果使用数据库来存储也是大材小用,所以可以将信息直接已经以 json 文件的方式存储在本地。


示例代码如下:


import { loggerError, loggerSuccess, getDirPath } from '@/util'
import { loadFile, writeFile } from '@/util/file'

interface ITpl {
tplUrl: string
name: string
desc: string
}

const addTpl = async (tplUrl: string, name: string, desc: string) => {
const cacheTpl = getDirPath('../cacheTpl')
try {
const tplConfig = loadFile<ITpl[]>(`${cacheTpl}/.tpl.json`)
let file = [{
tplUrl,
name,
desc
}]
if (tplConfig) {
const isExist = tplConfig.some(tpl => tpl.name === name)
if (isExist) {
file = tplConfig.map(tpl => {
if (tpl.name === name) {
return {
tplUrl,
name,
desc
}
}
return tpl
})
} else {
file = [
...tplConfig,
...file
]
}
}
writeFile(cacheTpl, '.tpl.json', JSON.stringify(file, null, "\t"))
loggerSuccess('Add Template Successful!')
} catch (error) {
loggerError(error)
}
}

export {
addTpl,
}

这里我们需要对是否保存还是更新模板做一个简单的流程判断:



  1. 判断当前是否存在 tpl 的缓存文件,如果已存在缓存文件,那么需要跟当前的模板信息合并,如果不存在的话则需要创建文件,将获取的信息保存进去。

  2. 如果当前已存在缓存文件,需要根据 name 判断是已经被缓存了,如果被缓存了的话,则根据 name 来更新对应的模板信息。


接下来,我们来演示一下,使用的效果。


根据之前的操作,构建完 CLI 之后,运行 fe-cil add tpl 可以得到如下的结果:


image.png


那么在对应的路径可以看到已经将这条模板信息缓存下来了。


image.png


如上,我们已经完成一个简单的本地对模板信息添加与修改功能,同样删除也是类似的操作,根据自己的实际需求开发即可。


下载模板


在保存了模板之后,我们需要选择对应的模板下载了。


下载可以使用 download-git-repo 作为 CLI 下载的插件,这是一款非常好用的插件,支持无 clone 去下载对应的模板,非常适合我们的项目。



download-git-repo 是一款下载 git repository 的工具库,它提供了简写与 direct:url 直接下载两种方式,同时也提供直接下载代码与 git clone 的功能,非常使用与方便。



同样在下载模板的时候,我们需要给用户展示当前的保存好的模板列表,这里同样需要使用到 inquirer 工具。



  1. 使用 inquirer 创建 list 选择交互模式,读取本地模板列表,让用户选择需要的模板


export const selectTpl = () => {
const tplList = getTplList()
const promptList = [
{
type: 'list',
message: '请选择模板下载:',
name: 'name',
choices: tplList && tplList.map((tpl: ITpl) => tpl.name)
},
{
type: 'input',
message: '下载路径:',
name: 'path',
default({ name }: { name: string }) {
return name.substring(name.lastIndexOf('/') + 1)
}
}
];

inquirer.prompt(promptList).then((answers: any) => {
const { name, path } = answers
const select = tplList && tplList.filter((tpl: ITpl) => tpl.name)
const tplUrl = select && select[0].tplUrl || ''
loadTpl(name, tplUrl, path)
})
}


  1. 使用 download-git-repo 下载对应的模板


export const loadTpl = (name: string, tplUrl: string, path: string) => {
download(`direct:${tplUrl}`, getCwdPath(`./${path}`), (err: string) => {
if (err) {
loggerError(err)
} else {
loggerSuccess(`Download ${name} Template Successful!`)
}
})
}

但是问题来了,如果选择 direct 的模式,那么下载的是一个 zip 的地址,而不是正常的 git 地址,那么我们上述的地址就无效了,所以在正式下载代码之前需要对地址做一层转换。


首先看拉取规则,正常的 git 地址是 https://github.com/boty-design/react-tpl,而实际在 github 中下载的地址则是 https://codeload.github.com/boty-design/react-tpl/zip/refs/heads/main,可以看到对比正常的 github 链接的话,域名跟链接都有所改变,但是一定有项目名跟团队名,所以我们在存储的时候可以将 boty-design/react-tpl 拆出来,后期方便我们组装。


const { pathname } = new URL(tplUrl)
if (tplUrl.includes('github.com')) {
reTpl.org = pathname.substring(1)
reTpl.downLoadUrl = 'https://codeload.github.com'
}

如上述代码,解析 tplUrl 拿到的 pathname 就是我们需要的信息,再 dowload 模板的时候,重新组装下载链接即可。


image.png


image.png


如上图所示,我们可以将公共的模板下载到本地,方便同学正常开发了,但是此时还有一个问题,那就是上面的分支是 main 分支,不是每一个模板都有这个分支,可控性太差,那么我们怎么拿到项目所有的分支来选择性下载呢。


Github Api


在 Github 中对于开源、不是私有的项目,可以省去授权 token 的步骤,直接使用 Github Api 获取到对应的信息。


所以针对上面提到问题,我们可以借助 Github Api 提供的能力来解决。


获取分支的链接是 https://api.github.com/repos/boty-design/react-tpl/branches,在开发之前我们可以使用 PostMan 来测试一下是否正常返回我们需要的结果。


image.png


如上可以看到,已经能通过 Github Api 拿到我们想要的分支信息了。



如果出现了下述错误的话,没关系,只是 github 限制访问的频率罢了



image.png


针对上述的问题,我们需要的是控制频率、使用带条件的请求或者使用 token 请求 Github Api 的方式来规避,但是鉴于模板来说,一般请求频率也不会很高,只是我在开发的时候需要不断的请求来测试,才会出现这种问题,各位同学有兴趣的话可以自己试试其他的解决方案。


分支代码优化


未命名文件 (1).png


在预研完 Github Api 之后,接下来就需要对拿到的信息做一层封装,例如只有一条分支的时候用户可以直接下载模板,如果请求到多条分支的时候,则需要显示分支让用户自由选择对应的分支下载模板,整体的业务流程图如上所示。


主要逻辑代码如下:


export const selectTpl = async () => {
const prompts: any = new Subject();
let select: ITpl
let githubName: string
let path: string
let loadUrl: string

try {
const onEachAnswer = async (result: any) => {
const { name, answer } = result
if (name === 'name') {
githubName = answer
select = tplList.filter((tpl: ITpl) => tpl.name === answer)[0]
const { downloadUrl, org } = select
const branches = await getGithubBranch(select) as IBranch[]
loadUrl = `${downloadUrl}/${org}/zip/refs/heads`
if (branches.length === 1) {
loadUrl = `${loadUrl}/${branches[0].name}`
prompts.next({
type: 'input',
message: '下载路径:',
name: 'path',
default: githubName
});
} else {
prompts.next({
type: 'list',
message: '请选择分支:',
name: 'branch',
choices: branches.map((branch: IBranch) => branch.name)
});
}
}
if (name === 'branch') {
loadUrl = `${loadUrl}/${answer}`
prompts.next({
type: 'input',
message: '下载路径:',
name: 'path',
default: githubName
});
}
if (name === 'path') {
path = answer
prompts.complete();
}
}

const onError = (error: string) => {
loggerError(error)
}

const onCompleted = () => {
loadTpl(githubName, loadUrl, path)
}

inquirer.prompt(prompts).ui.process.subscribe(onEachAnswer, onError, onCompleted);

const tplList = getTplList() as ITpl[]

prompts.next({
type: 'list',
message: '请选择模板:',
name: 'name',
choices: tplList.map((tpl: ITpl) => tpl.name)
});
} catch (error) {
loggerError(error)
}
}

上述代码,我们可以看到使用了 RXJS 来动态的渲染交互问题,因为存在一些模板的项目分支只有一个的情况。如果我们每次都需要用户都去选择分支是有多余累赘的,所以固定的问题式交互已经不适用了,我们需要借助 RXJS 动态添加 inquirer 问题,通过获取的分支数量来判断是否出现选择分支这个选项,提高用户体验。



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

收起阅读 »

CSS为什么这么难学?方法很重要!

大家好,我是零一。前段时间我在知乎刷到这样一个提问:为什么CSS这么难学? 看到这个问题以后,我仔细一想,CSS学习起来好像是挺困难的,它似乎没有像JavaScript那样非常系统的学习大纲,大家平时也不会用到所有的CSS,基本上用来用去就是那么几个常用的属...
继续阅读 »

大家好,我是零一。前段时间我在知乎刷到这样一个提问:为什么CSS这么难学?


知乎某用户提问


看到这个问题以后,我仔细一想,CSS学习起来好像是挺困难的,它似乎没有像JavaScript那样非常系统的学习大纲,大家平时也不会用到所有的CSS,基本上用来用去就是那么几个常用的属性,甚至就连很多培训机构的入门教学视频都也只会教你一些常用的CSS(不然你以为一个几小时的教学视频怎么能让你快速入门CSS的呢?)


一般别人回答你CSS很好学也是因为它只用那些常用的属性,他很有可能并没有深入去了解。要夸张一点说,CSS应该也能算作一门小小的语言了吧,深入研究进去,知识点也不少。我们如果不是专门研究CSS的,也没必要做到了解CSS的所有属性的使用以及所有后续新特性的语法,可以根据工作场景按需学习,但要保证你学习的属性足够深入~


那么我们到底该如何学习CSS呢? 为此我列了一个简单的大纲,想围绕这几点大概讲一讲


CSS学习大纲


一、书籍、社区文章


这应该是大家学习CSS最常见的方式了(我亦如此)。有以下几个场景:


场景一:开发中遇到「文本字数超出后以省略号(...)展示」的需求,打开百度搜索:css字数过多用省略号展示,诶~搜到了!ctrl+c、ctrl+v,学废了,完工!


搜索引擎学习法


场景二:某天早晨逛技术社区,看到一篇关于CSS的文章,看到标题中有个CSS属性叫resizeresize属性是啥,我咋没用过?点进去阅读得津津有味~ two minutes later ~ 奥,原来还有这个属性,是这么用的呀,涨姿势了!


社区博客学习法


场景三:我决定了,我要好好学CSS,打开购物网站搜索:CSS书籍,迅速下单!等书到了,开始每天翻阅学习。当然了此时又有好几种情况了,分别是:



  • 就只有刚拿到书的第一天翻阅了一下,往后一直落灰

  • 看了一部分,但又懒得动手敲代码,最终感到无趣放弃了阅读

  • 认认真真看完了书,也跟着书上的代码敲了,做了很多笔记,最终学到了很多



无论是上面哪几种方式,我觉得都是挺不错的,顺便再给大家推荐几个不错的学习资源



毕竟站在巨人的肩膀上,才是最高效的,你们可以花1个小时学习到大佬们花1天才总结出来的知识


二、记住CSS的数据类型


CSS比较难学的另一个点,可能多半是因为CSS的属性太多了,而且每个属性的值又支持很多种写法,所以想要轻易记住每个属性的所有写法几乎是不太可能的。最近在逛博客时发现原来CSS也有自己的数据类型,这里引用一下张鑫旭大佬的CSS值类型文档大全,方便大家后续查阅


简单介绍一下CSS的数据类型就是这样的:


CSS数据类型


图中用<>括起来的表示一种CSS数据类型,介绍一下图中几个类型:



  • :表示值可以是数字

  • :表示元素的尺寸长度,例如3px33em34rem

  • :表示基于父元素的百分比,例如33%

  • :表示值既可以是 ,也可以是

  • :表示元素的位置。值可以是 left/right/top/bottom


来看两个CSS属性:



  • 第一个是width,文档会告诉你该属性支持的数据类型有 ,那么我们就知道该属性有以下几种写法:width: 1pxwidth: 3remwidth: 33emwidth: 33%

  • 第二个属性是background-position,文档会告诉你该属性支持的数据类型有 ,那么我们就知道该属性有以下几种写法:background-position: leftbackground-position: right background-position: topbackground-position: bottombackground-position: 30%background-position: 3rem


从这个例子中我们可以看出,想要尽可能得记住更多的CSS属性的使用,可以从记住CSS数据类型(现在差不多有40+种数据类型)开始,这样你每次学习新的CSS属性时,思路就会有所转变,如下图


没记住CSS数据类型的我:


之前的思想


记住CSS数据类型的我:


现在的思想


不知道你有没有发现,如果文档只告诉你background-position支持 数据类型,你确定你能知道该属性的全部用法吗?你确实知道该属性支持background-position: 3rem这样的写法,因为你知道 数据类型包含了 数据类型,但你知道它还支持background-position: bottom 50px right 100px;这样的写法吗?为什么可以写四个值并且用空格隔开?这是谁告诉你的?


这就需要我们了解CSS的语法了,请认真看下一节


三、读懂CSS的语法


我之前某个样式中需要用到裁剪的效果,所以准备了解一下CSS中的clip-path属性怎么使用,于是就查询了比较权威的clip-path MDN,看着看着,我就发现了这个


clip-path 语法


我这才意识到我竟然连CSS的语法都看不懂。说实话,以前无论是初学CSS还是临时找一下某个CSS属性的用法,都是直接百度,瞬间就能找到自己想要的答案(例如菜鸟教程),而这次,我是真的傻了! 因为本身clip-path这个属性就比较复杂,支持的语法也比较多,光看MDN给你的示例代码根本无法Get到这个属性所有的用法和含义(菜鸟教程就更没法全面地教你了)


于是我就顺着网线去了解了一下CSS的语法中的一些符号的含义,帮助我更好得理解语法


因为关于CSS语法符号相关的知识在CSS属性值定义语法 MDN上都有一篇超级详细的介绍了(建议大家一定要先看看MDN这篇文章!!非常通俗易懂),所以我就不多做解释了,这里只放几个汇总表格


属性组合符号


解读CSS语法


以本节clip-path的语法为例,我们来简单对其中某一个属性来进行解读(只会解读部分哦,因为解读全部的话篇幅会很长很长)


先看看整体的结构


clip-path的语法


一共分为四部分,顺序是从上到下的,每两个部分之间都以where来连接,表示的是where下面的部分是对上面那个部分的补充解释


:表示的是clip-path这个属性支持的写法为:要不只写 数据类型的值,要不就最起码从 这两者之间选一种类型的值来写,要不就为none


:我们得知①中的 数据类型支持的写法为:inset()circle()ellipse()polygon()path()这5个函数


:因为我们想了解circle()这个函数的具体使用,所以就先只看这个了。我们得知circle()函数的参数支持 两种数据结构,且两者都是可写可不写,但如果要写 ,那前面必须加一个at


:首先看到 支持的属性是 (这个顾名思义就是)、closest-sidefarthest-side。而 数据类型的语法看起来就比较复杂了,我们单独来分析,因为真的非常非常长,我将 格式化并美化好给你展现出来,便于你们阅读(我也建议你们如果在学习某个属性的语法时遇到这么长的语法介绍,也像我一下把它格式化一下,这样方便你们阅读和理解)


<position>数据类型的语法


如图可得,整体分为三大部分,且这三部分是互斥关系,即这三部分只能出现一个,再根据我们前面学习的CSS语法的符号,就可以知道怎么使用了,因为这里支持的写法太多了,我直接列个表格吧(其实就是排列组合)!如果还有不懂的,你们可以仔细阅读一下MDN的语法介绍或者也可以评论区留言问我,我看到会第一时间回复!


类型支持的写法


嚯!累死我了,这支持的写法也太多太多了吧!


四、多动手尝试


上一节,我们在学习clip-path属性的语法以后,知道了我们想要的圆圈裁剪(circle())的语法怎么写,那么你就真的会了吗?可能你看了MDN给你举的例子,知道了circle(40%)大致实现的效果是咋样的,如下图


MDN clip-path的简单案例


如我前文说的一样,MDN只给你列举了circle()这个函数最简单的写法,但我们刚刚学习了其语法,得知还有别的写法(例如circle(40% at left)),而且MDN文档也只是告诉你支持哪些语法,它也并没有明确告诉你,哪个语法的作用是怎么样的,能实现什么样的效果。


此时就需要我们自己上手尝试了






<span class="scss">尝试<span class="hljs-attribute">clip-path</span>的circle()的使用</span>







看一下效果,嗯,跟MDN展示的是一样的


clip-path: circle(40%)


再修改一下值clip-path: circle(60%),看看效果


clip-path: circle(60%)


我似乎摸出了规律,看样子是以元素的中心为基准点,60%的意思就是从中心到边缘长度的60%为半径画一个圆,裁剪掉该圆之外的内容。这些都是MDN文档里没有讲到的,靠我亲手实践验证出来的。


接下来我们来试试其它的语法~


试试将值改成clip-path: circle(40% at top)


clip-path: circle(40% at top)


诶?很神奇!为什么会变成这个样子,我似乎还没找到什么规律,再把值改一下试试clip-path: circle(80% at top)


clip-path: circle(80% at top)


看样子圆心挪到了元素最上方的中间,然后以圆心到最下面边缘长度的80%为半径画了个圆进行了裁剪。至此我们似乎明白了circle()语法中at 后面的数据类型是干什么的了,大概就是用来控制裁剪时画的圆的圆心位置


剩下的时间就交给你自己来一个一个试验所有的语法了,再举个简单的例子,比如你再试一下clip-path: circle(40% at 30px),你一定好奇这是啥意思,来看看效果


clip-path: circle(40% at 30px)


直观上看,整个圆向左移动了一些距离,在我们没设置at 30px时,圆心是在元素的中心的,而现在似乎向右偏移了,大胆猜测at 30px的意思是圆心的横坐标距离元素的最左侧30px


接下来验证一下我们的猜测,继续修改其值clip-path: circle(40% at 0)


clip-path: circle(40% at 0)


很明显此时的圆心是在最左侧的中间部分,应该可以说是证明了我们刚才的猜测了,那么不妨再来验证一下纵坐标的?继续修改值clip-path: circle(40% at 0 0)


clip-path: circle(40% at 0 0)


不错,非常顺利,at 0 0中第二个0的意思就是圆心纵坐标离最上方的距离为0的意思。那么我们此时就可以放心得得出一个结论了,对于像30px33em这样的 数据类型的值,其对应的坐标是如图所示的


坐标情况


好了,本文篇幅也已经很长了,我就不继续介绍其它语法的使用了,刚才纯粹是用来举个例子,因为本文我们本来就不是在介绍circle()的使用教程,感兴趣的读者可以下去自己动手实践哦~


所以实践真的很重要很重要!! MDN文档没有给你列举每种语法对应的效果,因为每种都列出来,文档看着就很杂乱了,所以这只能靠你自己。记得张鑫旭大佬在一次直播中讲到,他所掌握的CSS的特性,也都是用大量的时间去动手试出来的,也不是看看啥文档就能理解的,所以你在大佬们的一篇文章中了解到的某个CSS属性的使用,可能是他们花费几小时甚至十几个小时研究出来的。


CSS很多特性会有兼容性问题,因为市面上有很多家浏览器厂商,它们支持的程度各不相同,而我们平常了解CSS某个属性的兼容性,是这样的


查看MDN的某个属性的浏览器兼容性


clip-path的浏览器兼容性


通过Can I Use来查找某个属性的浏览器兼容性


can i use


这些都是正确的,但有时候可能某些CSS属性的浏览器兼容性都无法通过这两个渠道获取到,那么该怎么办呢?手动试试每个浏览器上该属性的效果是否支持呗(鑫旭大佬说他以前也会这么干),这点我就不举例子了,大家应该能体会到


☀️ 最后


其实每个CSS大佬都不是因为某些快捷的学习路径而成功的,他们都是靠着不断地动手尝试、记录、总结各种CSS的知识,也会经常用学到的CSS知识去做一个小demo用于巩固,前几个月加了大漠老师的好友,我就经常看到他朋友圈有一些CSS新特性的demo演示代码和文章(真心佩服),coco大佬也是,也经常会发一些单纯用CSS实现的炫酷特效(据说没有他实现不了的特效哦~)


另外,如果想要更加深入,你们还可以关注一下CSS的规范,这个比较权威的就是W3C的CSS Working Group了,里面有很多CSS的规范文档


w3c css规范


好了,再推荐几本业界公认的还算不错的书籍吧~例如《CSS权威指南》、《CSS揭秘》、《CSS世界》、《CSS新世界》等等...


最后对于「如何学习CSS?」这个话题,你还有什么问题或者你觉得还不错的学习方法吗?欢迎在评论区留言讨论~



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

收起阅读 »

前端面试知识点(四)

9、ES6 Module 相对于 CommonJS 的优势是什么?温馨提示:如果你只是想知道本题的答案,那么直接进入传送门 16.8.2 Static module structure 。除此之外,以下 ES Module 的代码只在 No...
继续阅读 »

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

温馨提示:如果你只是想知道本题的答案,那么直接进入传送门 16.8.2 Static module structure 。除此之外,以下 ES Module 的代码只在 Node.js 环境中进行了测试,感兴趣的同学可以使用浏览器进行再测试。对不同规范模块的代码编译选择了 Webpack,感兴趣的同学也可以采用 Rollup 进行编译测试。

关于 ES Module 和 CommonJS 的规范以及语法,这里不再详细叙述,如果你还不了解这两者的语法糖,可以查看 ECMAScript 6 入门 / Module 语法ES Module 标准以及 Node.js 的 CommonJS 模块,两者的主要区别如下所示:

类型ES ModuleCommonJS
加载方式编译时运行时
引入性质引用 / 只读浅拷贝 / 可读写
模块作用域thisthis / __filename / __dirname...

9.1 加载方式

加载方式是 ES Module 和 CommonJS 的最主要区别,这使得两者在编译时运行时上各有优劣。首先来看一下 ES Module 在加载方式上的特性,如下所示:

// 编译时:VS Code 鼠标 hover 到 b 时可以显示出 b 的类型信息
import { b } from './b';

const a = 1;
// WARNING: 具有逻辑
if(a === 1) {
// 编译时:ESLint: Parsing error: 'import' and 'export' may only appear at the top level
// 运行时:SyntaxError: Unexpected token '{'
// TIPS: 这里可以使用 import() 进行动态导入
import { b } from './b';
}

const c = 'b';
// WARNING: 含有变量
// 编译时:ESLint:Parsing error: Unexpected token `
// 运行时:SyntaxError: Unexpected template string
import { d } from `./${c}`;

CommonJS 相对于 ES Module 在加载方式上的特性如下所示:

const a = 1;

if(a === 1) {
// VS Code 鼠标 hover 到 b 时,无法显示出 b 的类型信息
const b = require('./b');
}

const c = 'b';
const d = require(`./${c}`);

大家可能知道上述语法的差异性,接下来通过理论知识重点讲解一下两者产生差异的主要原因。在前端知识点扫盲(一)/ 编译器原理中重点讲解了整个编译器的执行阶段,如下图所示: image.png ES Module 是采用静态的加载方式,也就是模块中导入导出的依赖关系可以在代码编译时就确定下来。如上图所示,代码在编译的过程中可以做的事情包含词法和语法分析、类型检查以及代码优化等等。因此采用 ES Module 进行代码设计时可以在编译时通过 ESLint 快速定位出模块的词法语法错误以及类型信息等。ES Module 中会产生一些错误的加载方式,是因为这些加载方式含有逻辑和变量的运行时判断,只有在代码的运行时阶段才能确定导入导出的依赖关系,这明显和 ES Module 的加载机制不相符。

CommonJS 相对于 ES Module 在加载模块的方式上存在明显差异,是因为 CommonJS 在运行时进行加载方式的动态解析,在运行时阶段才能确定的导入导出关系,因此无法进行静态编译优化和类型检查。

温馨提示:注意 import 语法和 import() 的区别,import() 是 tc39 中的一种提案,该提案允许你可以使用类似于 import(`${path}/foo.js`) 的导入语句(估计是借鉴了 CommonJS 可以动态加载模块的特性),因此也允许你在运行时进行条件加载,也就是所谓的懒加载。除此之外,import 和 import() 还存在其他一些重要的区别,大家还是自行谷歌一下。

9.2 编译优化

由于 ES Module 是在编译时就能确定模块之间的依赖关系,因此可以在编译的过程中进行代码优化。例如:

// hello.js 
export function a() {
console.log('a');
}

export function b() {
console.log('b');
}

// index.js
// TIPS: Webpack 编译入口文件
// 这里不引入 function b
import { a } from './hello';
console.log(a);

使用 Webpack 5.47.1 (Webpack Cli 4.7.2)进行代码编译,生成的编译产物如下所示:

(()=>{"use strict";console.log((function(){console.log("a")}))})();

可以发现编译生成的产物没有 function b 的代码,这是在编译阶段对代码进行了优化,移除了未使用的代码(Dead Code),这种优化的术语被叫做 Tree Shaking

温馨提示:你可以将应用程序想象成一棵树。绿色表示实际用到的 Source Code(源码)和 Library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。

温馨提示:在 ES Module 中可能会因为代码具有副作用(例如操作原型方法以及添加全局对象的属性等)导致优化失败,如果想深入了解 Tree Shaking 的更多优化注意事项,可以深入阅读你的 Tree-Shaking 并没什么卵用

为了对比 ES Module 的编译优化能力,同样采用 CommonJS 规范进行模块导入:

// hello.js
exports.a = function () {
console.log('a');
};

exports.b = function () {
console.log('b');
};

// index.js
// TIPS: Webpack 编译入口文件
const { a } = require('./hello');
console.log(a);

使用 Webpack 进行代码编译,生成的编译产物如下所示:

(() => {
var o = {
418: (o, n) => {
(n.a = function () {
console.log('a');
}),
// function b 的代码并没有被去除
(n.b = function () {
console.log('b');
});
},
},
n = {};
function r(t) {
var e = n[t];
if (void 0 !== e) return e.exports;
var s = (n[t] = { exports: {} });
return o[t](s, s.exports, r), s.exports;
}
(() => {
const { a: o } = r(418);
console.log(o);
})();
})();

可以发现在 CommonJS 模块中,尽管没有使用 function b,但是代码仍然会被打包编译,正是因为 CommonJS 模块只有在运行时才能进行同步导入,因此无法在编译时确定是否 function b 是一个 Dead Code。

温馨提示:在 Node.js 环境中一般不需要编译 CommonJS 模块代码,除非你使用了当前 Node 版本所不能兼容的一些新语法特性。

大家可能会注意到一个新的问题,当我们在制作工具库或者组件库的时候,通常会将库包编译成 ES5 语法,这样尽管 Babel 以及 Webpack 默认会忽略 node_modules 里的模块,我们的项目在编译时引入的这些模块仍然能够做到兼容。在这个过程中,如果你制作的库包体积非常大,你又不提供非常细粒度的按需引入的加载方式,那么你可以编译你的源码使得编译产物可以支持 ES Module 的导入导出模式(注意只支持 ES6 中模块的语法,其他的语法仍然需要被编译成 ES5),当项目真正引入这些库包时可以通过 Tree Shaking 的特性在编译时去除未引入的代码(Dead Code)。

温馨提示:如果你想了解如何使发布的 Npm 库包支持 Tree Shaking 特性,可以查看 defense-of-dot-js / Typical Usage、 Webpack / Final Stepspgk.module 以及 rollup.js / Tree Shaki…

Webpack 对于 module 字段的支持的描述提示:The module property should point to a script that utilizes ES2015 module syntax but no other syntax features that aren't yet supported by browsers or node. This enables webpack to parse the module syntax itself, allowing for lighter bundles via tree shaking if users are only consuming certain parts of the library.

9.3 加载原理 & 引入性质

温馨提示:下述理论部分以及图片内容均出自于 2018 年的文章 ES modules: A cartoon deep-dive,如果想要了解更多原理信息可以查看 TC39 的 16.2 Modules

在 ES Module 中使用模块进行开发,其实是在编译时构建模块之间的依赖关系图。在浏览器或者服务的文件系统中运行 ES6 代码时,需要解析所有的模块文件,然后将模块转换成 Module Record 数据结构,具体如下图所示: 05_module_record-768x441.png

事实上, ES Module 的加载过程主要分为如下三个阶段:

  • 构建(Construction):主要分为查找、加载(在浏览器中是下载文件,在本地文件系统中是加载文件)、然后把文件解析成 Module Record。
  • 实例化(Instantiation):给所有的 Module Record 分配内存空间(此刻还没有填充值),并根据导入导出关系确定各自之间的引用关系,确定引用关系的过程称为链接(Linking)。
  • 运行(Evaluation):运行代码,给内存地址填充运行时的模块数据。

温馨提示:import 的上述三个阶段其实在 import() 中体现的更加直观(尽管 import 已经被多数浏览器支持,但是我们在真正开发和运行的过程中仍然会使用编译后的代码运行,而不是采用浏览器 script 标签的远程地址的动态异步加载方式),而 import() 事实上如果要实现懒加载优化(例如 Vue 里的路由懒加载,更多的是在浏览器的宿主环境而不是 Node.js 环境,这里不展开更多编译后实现方式的细节问题),大概率要完整经历上述三个阶段的异步加载过程,具体再次查看 tc39 动态提案:This proposal adds an import(specifier) syntactic form, which acts in many ways like a function (but see below). It returns a promise for the module namespace object of the requested module, which is created after fetching, instantiating, and evaluating all of the module's dependencies, as well as the module itself.

07_3_phases.png ES Module 模块加载的三个阶段分别需要在编译时和运行时进行(可能有的同学会像我一样好奇实例化阶段到底是在编译时还是运行时进行,根据 tc39 动态加载提案里的描述可以得出你想要的答案:The existing syntactic forms for importing modules are static declarations. They accept a string literal as the module specifier, and introduce bindings into the local scope via a pre-runtime "linking" process.),而 CommonJS 规范中的模块是在运行时同步顺序执行,模块在加载的过程中不会被中断,具体如下图所示: 43_cjs_cycle.png 上图中 main.js 在运行加载 counter.js 时,会先等待 counter.js 运行完成后才能继续运行代码,因此在 CommonJS 中模块的加载是阻塞式的。CommonJS 采用同步阻塞式加载模块是因为它只需要从本地的文件系统中加载文件,耗费的性能和时间很少,而 ES Module 在浏览器(注意这里说的是浏览器)中运行的时候需要下载文件然后才能进行实例化和运行,如果这个过程是同步进行,那么会影响页面的加载性能。

从 ES Module 链接的过程可以发现模块之间的引用关系是内存的地址引用,如下所示:

// hello.js
export let a = 1;

setTimeout(() => {
a++;
}, 1000);


// index.js
import { a } from './hello.js';

setTimeout(() => {
console.log(a); // 2
}, 2000);

在 Node (v14.15.4)环境中运行上述代码得到的执行结果是 2,对比一下 CommonJS 规范的执行:

// hello.js
exports.a = 1;

setTimeout(() => {
exports.a++;
}, 1000);


// index.js
let { a } = require('./hello');

setTimeout(() => {
console.log(a); // 1
}, 2000);

可以发现打印的结果信息和 ES Module 的结果不一样,这里的执行结果为 1。产生上述差异的根本原因是实例化的方式不同,如下图所示:1665647773-5acd908e6e76f_fix732.png

在 ES Module 的导出中 Module Record 会实时跟踪(wire up 在这里理解为链接或者引用的意思)和绑定每一个导出变量对应的内存地址(从上图可以发现值还没有被填充,而 function 则可以在链接阶段进行初始化),导入同样对应的是导出所对应的同一个内存地址,因此对导入变量进行处理其实处理的是同一个引用地址的数据,如下图所示:

1181374600-5acd91c0798bf_fix732.png

CommonJS 规范在导出时事实上导出的是值拷贝,如下图所示:

516296747-5acd92fbbb9e6_fix732.png

在上述代码执行的过程中先对变量 a 进行值拷贝,因此尽管设置了定时器,变量 a 被引入后打印的信息仍然是 1。需要注意的是这种拷贝是浅拷贝,如下所示:

// hello.js
exports.a = {
value: 1,
};

setTimeout(() => {
exports.a.value++;
}, 1000);

// index.js
let { a } = require('./hello');

setTimeout(() => {
console.log(a.value); // 2
}, 2000);

接下来对比编译后的差异,将 ES Module 的源码进行编译(仍然使用 Webpack),编译之后的代码如下所示:

(() => {
'use strict';
let e = 1;
setTimeout(() => {
e++;
}, 1e3),
setTimeout(() => {
console.log(e);
}, 2e3);
})();

可以看出,将 ES Module 的代码进行编译后,使用的是同一个变量值,此时将 CommonJS 的代码进行编译:

(() => {
var e = {
418: (e, t) => {
// hello.js 中的模块代码
(t.a = 1),
setTimeout(() => {
t.a++;
}, 1e3);
},
},
t = {};
function o(r) {
// 开辟模块的缓存空间
var s = t[r];
// 获取缓存信息,每次返回相同的模块对象信息
if (void 0 !== s) return s.exports;
// 开辟模块对象的内存空间
var a = (t[r] = { exports: {} });
// 逗号运算符,先运行模块代码,赋值模块对象的值,然后返回模块信息
// 由于缓存,模块代码只会被执行一次
return e[r](a, a.exports, o), a.exports;
}
(() => {
// 浅拷贝
let { a: e } = o(418);
setTimeout(() => {
// 尽管 t.a ++,这里输出的仍然是 1
console.log(e);
}, 2e3);
})();
})();

可以发现 CommonJS 规范在编译后会缓存模块的信息,从而使得下一次将从缓存中直接获取模块数据。除此之外,缓存会使得模块代码只会被执行一次。查看 Node.js 官方文档对于 CommonJS 规范的缓存描述,发现 Webpack 的编译完全符合 CommonJS 规范的缓存机制。了解了这个机制以后,你会发现多次使用 require 进行模块加载不会导致代码被执行多次,这是解决无限循环依赖的一个重要特征。

除了引入的方式可能会有区别之外,引入的代码可能还存在一些区别,比如在 ES Module 中:

// hello.js
export function a() {
console.log('a this: ', this);
}


// index.js
import { a } from './hello.js';

// a = 1;
^
// TypeError: Assignment to constant variable.
// ...
// at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
// at async Loader.import (internal/modules/esm/loader.js:166:24)
// at async Object.loadESM (internal/process/esm_loader.js:68:5)
a = 1;

使用 Node.js 直接运行上述 ES Module 代码,是会产生报错的,因为导入的变量根据提示可以看出是只读变量,而如果采用 Webpack 进行编译后运行,则没有上述问题,除此之外 CommonJS 中导入的变量则可读可写。当然除此之外,你也可以尝试更多的其他方面,比如:

// hello.js

// 非严格模式
b = 1;

export function a() {
console.log('a this: ', this);
}

// index.js
import { a } from './hello.js';

console.log('a: ', a);

你会发现使用 Node.js 环境执行上述 ES Module 代码,会直接抛出下述错误信息:

ReferenceError: b is not defined
at file:///Users/ziyi/Desktop/Gitlab/Explore/module-example/esmodule/hello.js:1:3
at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
at async Loader.import (internal/modules/esm/loader.js:166:24)
at async Object.loadESM (internal/process/esm_loader.js:68:5)

是因为 ES Module 的模块需要运行在严格模式下, 而 CommonJS 规范则没有这样的要求,如果你在仔细一点观察的话,会发现使用 Webpack 进行编译的时候,ES Module 编译的代码会在前面加上 "use strict",而 CommonJS 编译的代码没有。

9.4 模块作用域

大家会发现在 Node.js 的模块中设计代码时可以使用诸如 __dirname、__filename 之类的变量(需要注意在 Webpack 编译出的 CommonJS 前端产物中,并没有 __filename、__dirname 等变量信息,浏览器中并不需要这些文件系统的变量信息),是因为 Node.js 在加载模块时会对其进行如下包装:

// https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js#L206
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];

索性看到这个模块作用域的代码,我们就继续查看一下 require 的源码:

// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L997
Module.prototype.require = function(id) {
validateString(id, 'id');
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id,
'must be a non-empty string');
}
requireDepth++;
try {
return Module._load(id, this, /* isMain */ false);
} finally {
requireDepth--;
}
};

// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L757

// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call
// `NativeModule.prototype.compileForPublicLoader()` and return the exports.
// 3. Otherwise, create a new module for the file and save it to the cache.
// Then have it load the file contents before returning its exports
// object.
Module._load = function(request, parent, isMain) {
let relResolveCacheIdentifier;
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
// Fast path for (lazy loaded) modules in the same directory. The indirect
// caching is required to allow cache invalidation without changing the old
// cache key names.
relResolveCacheIdentifier = `${parent.path}\x00${request}`;
const filename = relativeResolveCache[relResolveCacheIdentifier];
// 有缓存,则走缓存
if (filename !== undefined) {
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
return cachedModule.exports;
}
delete relativeResolveCache[relResolveCacheIdentifier];
}
}

// `node:` 用于检测核心模块,例如 fs、path 等
// Node.js 文档:http://nodejs.cn/api/modules.html#modules_core_modules
// 这里主要用于绕过 require 缓存
const filename = Module._resolveFilename(request, parent, isMain);
if (StringPrototypeStartsWith(filename, 'node:')) {
// Slice 'node:' prefix
const id = StringPrototypeSlice(filename, 5);

const module = loadNativeModule(id, request);
if (!module?.canBeRequiredByUsers) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(filename);
}

return module.exports;
}

// 缓存处理
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded) {
const parseCachedModule = cjsParseCache.get(cachedModule);
if (!parseCachedModule || parseCachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
parseCachedModule.loaded = true;
} else {
return cachedModule.exports;
}
}

const mod = loadNativeModule(filename, request);
if (mod?.canBeRequiredByUsers) return mod.exports;

// Don't call updateChildren(), Module constructor already does.
const module = cachedModule || new Module(filename, parent);

if (isMain) {
process.mainModule = module;
module.id = '.';
}

Module._cache[filename] = module;
if (parent !== undefined) {
relativeResolveCache[relResolveCacheIdentifier] = filename;
}

let threw = true;
try {
module.load(filename);
threw = false;
} finally {
if (threw) {
delete Module._cache[filename];
if (parent !== undefined) {
delete relativeResolveCache[relResolveCacheIdentifier];
const children = parent?.children;
if (ArrayIsArray(children)) {
const index = ArrayPrototypeIndexOf(children, module);
if (index !== -1) {
ArrayPrototypeSplice(children, index, 1);
}
}
}
} else if (module.exports &&
!isProxy(module.exports) &&
ObjectGetPrototypeOf(module.exports) ===
CircularRequirePrototypeWarningProxy) {
ObjectSetPrototypeOf(module.exports, ObjectPrototype);
}
}

return module.exports;
};

温馨提示:这里没有将 wrapper 和 _load 的联系说清楚(最后如何在 _load 中执行 wrapper),大家可以在 Node.js 源码中跟踪一下看一下上述代码是怎么被执行的,是否是 eval 呢?不说了,脑壳疼,想要了解更多信息,可以查看 Node.js / vm。除此之外,感兴趣的同学也了解一下 import 语法在 Node.js 中的底层实现,这里脑壳疼,就没有深入研究了。

温馨提示的温馨提示:比如你在源码中找不到上述代码的执行链路,那最简单的方式就是引入一个错误的模块,让错误信息将错误栈抛出来,比如如下所示,你会发现最底下执行了 wrapSafe,好了你又可以开始探索了,因为你对 safe 这样的字眼一定感到好奇,底下是不是执行的时候用了沙箱隔离呢?

SyntaxError: Cannot use import statement outside a module
at wrapSafe (internal/modules/cjs/loader.js:979:16)
at Module._compile (internal/modules/cjs/loader.js:1027:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
at Module.load (internal/modules/cjs/loader.js:928:32)
at Function.Module._load (internal/modules/cjs/loader.js:769:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
at internal/main/run_main_module.js:17:47

温馨提示:是不是以前经常有面试官询问 exports 和 module.exports 有什么关联,其实根本不用纠结这个问题,因为两者指向的是同一个引用地址,你如果对 exports 进行重新赋值,那么引用发生了改变,你新引用的部分当然就不会导出了,因为从源码里可以看出,我们这里导出的是 module.exports。

接下来主要是重点看下 this 执行上下文的差异(注意这里只测试 Node.js 环境,编译后的代码可能会有差异),首先执行 ES Module 模块的代码:

// hello.js
export function a() {
console.log('this: ', this); // undefined
}

// index.js
import { a } from './hello.js';
a();

我们接着执行 CommonJS 的代码:

// hello.js
exports.a = function () {
console.log('this: ', this);
};

// index.js
let { a } = require('./hello');
a();

你会发现 this 的上下文环境是有信息的,可能是当前模块的信息,具体没有深究:

image.png

温馨提示:Node.js 的调试还能在浏览器进行?可以查看一下 Node.js 调试,当然你也可以使用 VS Code 进行调试,需要进行一些额外的 launch 配置,当然如果你觉得 Node.js 自带的浏览器调试方式太难受了,也可以想想办法,如何通过 IP 端口在浏览器中进行调试,并且可以做到代码变动监听调试。

大家可以不用太纠结代码的细致实现,只需要大致可以了解到 CommonJS 中模块的导入过程即可,事实上 Webpack 编译的结果大致可以理解为该代码的浏览器简易版。那还记得我之前在面试分享中的题目:两年工作经验成功面试阿里P6总结 / 如何在Node端配置路径别名(类似于Webpack中的alias配置),如果你阅读了上述源码,基本上思路就是 HACK 原型链上的 require 方法:

const Module = require('module');
const originalRequire = Module.prototype.require;

Module.prototype.require = function(id){
// 这里加入 path 的逻辑
return originalRequire.apply(this, id);
};

小结

目前的面试题答案系列稍微有些混乱,后续可能会根据类目对面试题进行简单分类,从而整理出更加体系化的答案。本篇旨在希望大家可以对面试题进行举一反三,从而加深理解(当我们问出一个问题的时候,可以衍生出 N 个问题)。


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

收起阅读 »

前端面试知识点(三)

6、简单描述一下 Babel 的编译过程? Babel 是一个源到源的转换编译器(Transpiler),它的主要作用是将 JavaScript 的高版本语法(例如 ES6)转换成低版本语法(例如 ES5),从而可以适配浏览器的兼容性。 温馨提示:如果某种高...
继续阅读 »

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


Babel 是一个源到源的转换编译器(Transpiler),它的主要作用是将 JavaScript 的高版本语法(例如 ES6)转换成低版本语法(例如 ES5),从而可以适配浏览器的兼容性。



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



image.png


从上图可知,Babel 的编译过程主要可以分为三个阶段:



  • 解析(Parse):包括词法分析和语法分析。词法分析主要把字符流源代码(Char Stream)转换成令牌流( Token Stream),语法分析主要是将令牌流转换成抽象语法树(Abstract Syntax Tree,AST)。

  • 转换(Transform):通过 Babel 的插件能力,将高版本语法的 AST 转换成支持低版本语法的 AST。当然在此过程中也可以对 AST 的 Node 节点进行优化操作,比如添加、更新以及移除节点等。

  • 生成(Generate):将 AST 转换成字符串形式的低版本代码,同时也能创建 Source Map 映射。


具体的流程如下所示:
image.png


举个栗子,如果要将 TypeScript 语法转换成 ES5 语法:


// 源代码
let a: string = 1;
// 目标代码
var a = 1;

6.1 解析(Parser)


Babel 的解析过程(源码到 AST 的转换)可以使用 @babel/parser,它的主要特点如下:



  • 支持解析最新的 ES2020

  • 支持解析 JSX、Flow & TypeScript

  • 支持解析实验性的语法提案(支持任何 Stage 0 的 PRS)


@babel/parser 主要是基于输入的字符串流(源代码)进行解析,最后转换成规范(基于 ESTree 进行调整)的 AST,如下所示:


import { parse } from '@babel/parser';
const source = `let a: string = 1;`;

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是支持解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

需要注意,在 Parser 阶段主要是进行词法和语法分析,如果词法或者语法分析错误,那么会在该阶段被检测出来。如果检测正确,则可以进入语法的转换阶段。


6.2 转换(Transform)


Babel 的转换过程(AST 到 AST 的转换)主要使用 @babel/traverse,该库包可以通过访问者模式自动遍历并访问 AST 树的每一个 Node 节点信息,从而实现节点的替换、移除和添加操作,如下所示:


import { parse } from '@babel/parser';
import traverse from '@babel/traverse';

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}

const source = `let a: string = 1;`;

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是可以解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

// 转换(Transform) 阶段
traverse(ast, {
// 访问变量声明标识符
VariableDeclaration(path) {
// 将 const 和 let 转换为 var
path.node.kind = 'var';
},
// 访问 TypeScript 类型声明标识符
TSTypeAnnotation(path) {
// 移除 TypeScript 的声明类型
path.remove();
},
});

关于 Babel 中的访问器 API,这里不再过多说明,如果想了解更多信息,可以查看 Babel 插件手册。除此之外,你可能已经注意到这里的转换逻辑其实可以理解为实现一个简单的 Babel 插件,只是没有封装成 Npm 包。当然,在真正的插件开发开发中,还可以配合 @babel/types 工具包进行节点信息的判断处理。



温馨提示:这里只是简单的一个 Demo 示例,在真正转换 let、const 等变量声明的过程中,还会遇到处理暂时性死区(Temporal Dead Zone, TDZ)的情况,更多详细信息可以查看官方的插件 babel-plugin-transform-block-scoping



6.3 生成(Generate)


Babel 的代码生成过程(AST 到目标代码的转换)主要使用 @babel/generator,如下所示:


import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}
const source = `let a: string = 1;`;

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是可以解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

// 转换(Transform) 阶段
traverse(ast, {
// 访问词法规则
VariableDeclaration(path) {
path.node.kind = 'var';
},

// 访问词法规则
TSTypeAnnotation(path) {
// 移除 TypeScript 的声明类型
path.remove();
},
});

// 生成(Generate)阶段
const { code } = generate(ast);
// code: var a = 1;
console.log('code: ', code);

如果你想了解上述输入源对应的 AST 数据或者尝试自己编译,可以使用工具 AST Explorer (也可以使用 Babel 官网自带的 Try It Out ),具体如下所示:


image.png



温馨提示:上述第三个框是以插件的 API 形式进行调用,如果想了解 Babel 的插件开发,可以查看 Babel 插件手册 / 编写你的第一个 Babel 插件



如果你觉得 Babel 的编译过程太过于简单,你可以尝试更高阶的玩法,比如自己设计词法和语法规则从而实现一个简单的编译器(Babel 内置了这些规则),你完全可以不只是做出一个源到源的转换编译器,而是实现一个真正的从 JavaScript (TypeScript) 到机器代码的完整编译器,包括实现中间代码 IR 以及提供机器的运行环境等,这里给出一个可以尝试这种高阶玩法的库包 antlr4ts(可以配合交叉编译工具链 riscv-gnu-toolchain,gcc编译工具的制作还是非常耗时的)。



阅读链接: Babel 用户手册Babel 插件手册






收起阅读 »

前端面试知识点(二)

语法 22、如何实现一个上中下三行布局,顶部和底部最小高度是 100px,中间自适应? 23、如何判断一个元素 CSS 样式溢出,从而可以选择性的加 title 或者 Tooltip? 24、如何让 CSS 元素左侧自动溢出(... 溢出在左侧)? The&n...
继续阅读 »

语法


22、如何实现一个上中下三行布局,顶部和底部最小高度是 100px,中间自适应?


23、如何判断一个元素 CSS 样式溢出,从而可以选择性的加 title 或者 Tooltip?


24、如何让 CSS 元素左侧自动溢出(... 溢出在左侧)?


The direction CSS property sets the direction of text, table columns, and horizontal overflow. Use rtl for languages written from right to left (like Hebrew or Arabic), and ltr for those written from left to right (like English and most other languages).


具体查看:developer.mozilla.org/en-US/docs/…


25、什么是沙箱?浏览器的沙箱有什么作用?


26、如何处理浏览器中表单项的密码自动填充问题?


27、Hash 和 History 路由的区别和优缺点?


28、JavaScript 中对象的属性描述符有哪些?分别有什么作用?


29、JavaScript 中 console 有哪些 api ?


The console object provides access to the browser's debugging console (e.g. the Web console in Firefox). The specifics of how it works varies from browser to browser, but there is a de facto set of features that are typically provided.


这里列出一些我常用的 API:



  • console.log

  • console.error

  • console.time

  • console.timeEnd

  • console.group


具体查看:developer.mozilla.org/en-US/docs/…


30、 简单对比一下 Callback、Promise、Generator、Async 几个异步 API 的优劣?


在 JavaScript 中利用事件循环机制(Event Loop)可以在单线程中实现非阻塞式、异步的操作。例如



我们重点来看一下常用的几种编程方式(Callback、Promise、Generator、Async)在语法糖上带来的优劣对比。


Callback


Callback(回调函数)是在 Web 前端开发中经常会使用的编程方式。这里举一个常用的定时器示例:


export interface IObj {
value: string;
deferExec(): void;
deferExecAnonymous(): void;
console(): void;
}

export const obj: IObj = {
value: 'hello',

deferExecBind() {
// 使用箭头函数可达到一样的效果
setTimeout(this.console.bind(this), 1000);
},

deferExec() {
setTimeout(this.console, 1000);
},

console() {
console.log(this.value);
},
};

obj.deferExecBind(); // hello
obj.deferExec(); // undefined

回调函数经常会因为调用环境的变化而导致 this 的指向性变化。除此之外,使用回调函数来处理多个继发的异步任务时容易导致回调地狱(Callback Hell):


fs.readFile(fileA, 'utf-8', function (err, data) {
fs.readFile(fileB, 'utf-8', function (err, data) {
fs.readFile(fileC, 'utf-8', function (err, data) {
fs.readFile(fileD, 'utf-8', function (err, data) {
// 假设在业务中 fileD 的读写依次依赖 fileA、fileB 和 fileC
// 或者经常也可以在业务中看到多个 HTTP 请求的操作有前后依赖(继发 HTTP 请求)
// 这些异步任务之间纵向嵌套强耦合,无法进行横向复用
// 如果某个异步发生变化,那它的所有上层或下层回调可能都需要跟着变化(比如 fileA 和 fileB 的依赖关系倒置)
// 因此称这种现象为 回调地狱
// ....
});
});
});
});


回调函数不能通过 return 返回数据,比如我们希望调用带有回调参数的函数并返回异步执行的结果时,只能通过再次回调的方式进行参数传递:


// 希望延迟 3s 后执行并拿到结果
function getAsyncResult(result: number) {
setTimeout(() => {
return result * 3;
}, 1000);
}

// 尽管这是常规的编程思维方式
const result = getAsyncResult(3000);
// 但是打印 undefined
console.log('result: ', result);

function getAsyncResultWithCb(result: number, cb: (result: number) => void) {
setTimeout(() => {
cb(result * 3);
}, 1000);
}

// 通过回调的形式获取结果
getAsyncResultWithCb(3000, (result) => {
console.log('result: ', result); // 9000
});


对于 JavaScript 中标准的异步 API 可能无法通过在外部进行 try...catch... 的方式进行错误捕获: 


try {
setTimeout(() => {
// 下述是异常代码
// 你可以在回调函数的内部进行 try...catch...
console.log(a.b.c)
}, 1000)

} catch(err) {
// 这里不会执行
// 进程会被终止
console.error(err)
}

上述示例讲述的都是 JavaScript 中标准的异步 API ,如果使用一些三方的异步 API 并且提供了回调能力时,这些 API 可能是非受信的,在真正使用的时候会因为执行反转(回调函数的执行权在三方库中)导致以下一些问题:



  • 使用者的回调函数设计没有进行错误捕获,而恰恰三方库进行了错误捕获却没有抛出错误处理信息,此时使用者很难感知到自己设计的回调函数是否有错误

  • 使用者难以感知到三方库的回调时机和回调次数,这个回调函数执行的权利控制在三方库手中

  • 使用者无法更改三方库提供的回调参数,回调参数可能无法满足使用者的诉求

  • ...


举个简单的例子:


interface ILib<T> {
params: T;
emit(params: T): void;
on(callback: (params: T) => void): void;
}

// 假设以下是一个三方库,并发布成了npm 包
export const lib: ILib<string> = {
params: '',

emit(params) {
this.params = params;
},

on(callback) {
try {
// callback 回调执行权在 lib 上
// lib 库可以决定回调执行多次
callback(this.params);
callback(this.params);
callback(this.params);
// lib 库甚至可以决定回调延迟执行
// 异步执行回调函数
setTimeout(() => {
callback(this.params);
}, 3000);
} catch (err) {
// 假设 lib 库的捕获没有抛出任何异常信息
}
},
};

// 开发者引入 lib 库开始使用
lib.emit('hello');

lib.on((value) => {
// 使用者希望 on 里的回调只执行一次
// 这里的回调函数的执行时机是由三方库 lib 决定
// 实际上打印四次,并且其中一次是异步执行
console.log(value);
});

lib.on((value) => {
// 下述是异常代码
// 但是执行下述代码不会抛出任何异常信息
// 开发者无法感知自己的代码设计错误
console.log(value.a.b.c)
});

Promise


Callback 的异步操作形式除了会造成回调地狱,还会造成难以测试的问题。ES6 中的 Promise (基于 Promise A + 规范的异步编程解决方案)利用有限状态机的原理来解决异步的处理问题,Promise 对象提供了统一的异步编程 API,它的特点如下:



  • Promise 对象的执行状态不受外界影响。Promise 对象的异步操作有三种状态: pending(进行中)、 fulfilled(已成功)和 rejected(已失败) ,只有 Promise 对象本身的异步操作结果可以决定当前的执行状态,任何其他的操作无法改变状态的结果

  • Promise 对象的执行状态不可变。Promise 的状态只有两种变化可能:从 pending(进行中)变为 fulfilled(已成功)或从 pending(进行中)变为 rejected(已失败)



温馨提示:有限状态机提供了一种优雅的解决方式,异步的处理本身可以通过异步状态的变化来触发相应的操作,这会比回调函数在逻辑上的处理更加合理,也可以降低代码的复杂度。



Promise 对象的执行状态不可变示例如下:


const promise = new Promise<number>((resolve, reject) => {
// 状态变更为 fulfilled 并返回结果 1 后不会再变更状态
resolve(1);
// 不会变更状态
reject(4);
});

promise
.then((result) => {
// 在 ES 6 中 Promise 的 then 回调执行是异步执行(微任务)
// 在当前 then 被调用的那轮事件循环(Event Loop)的末尾执行
console.log('result: ', result);
})
.catch((error) => {
// 不执行
console.error('error: ', error);
});

假设要实现两个继发的 HTTP 请求,第一个请求接口返回的数据是第二个请求接口的参数,使用回调函数的实现方式如下所示(这里使用 setTimeout 来指代异步请求):


// 回调地狱
const doubble = (result: number, callback: (finallResult: number) => void) => {
// Mock 第一个异步请求
setTimeout(() => {
// Mock 第二个异步请求(假设第二个请求的参数依赖第一个请求的返回结果)
setTimeout(() => {
callback(result * 2);
}, 2000);
}, 1000);
};

doubble(1000, (result) => {
console.log('result: ', result);
});


温馨提示:继发请求的依赖关系非常常见,例如人员基本信息管理系统的开发中,经常需要先展示组织树结构,并默认加载第一个组织下的人员列表信息。



如果采用 Promise 的处理方式则可以规避上述常见的回调地狱问题:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
// 将 resolve 改成 reject 会被 catch 捕获
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
// 将 resolve 改成 reject 会被 catch 捕获
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
return nextPromise(result);
})
.then((result) => {
// 2s 后打印 2000
console.log('result: ', result);
})
// 任何一个 Promise 到达 rejected 状态都能被 catch 捕获
.catch((err) => {
console.error('err: ', err);
});

Promise 的错误回调可以同时捕获 firstPromisenextPromise 两个函数的 rejected 状态。接下来考虑以下调用场景:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
nextPromise(result).then((result) => {
// 后打印
console.log('nextPromise result: ', result);
});
})
.then((result) => {
// 先打印
// 由于上一个 then 没有返回值,这里打印 undefined
console.log('firstPromise result: ', result);
})
.catch((err) => {
console.error('err: ', err);
});

首先 Promise 可以注册多个 then(放在一个执行队列里),并且这些 then 会根据上一次返回值的结果依次执行。除此之外,各个 Promise 的 then 执行互不干扰。 我们将示例进行简单的变换:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
// 返回了 nextPromise 的 then 执行后的结果
return nextPromise(result).then((result) => {
return result;
});
})
// 接着 nextPromise 的 then 执行的返回结果继续执行
.then((result) => {
// 2s 后打印 2000
console.log('nextPromise result: ', result);
})
.catch((err) => {
console.error('err: ', err);
});


上述例子中的执行结果是因为 then 的执行会返回一个新的 Promise 对象,并且如果 then 执行后返回的仍然是 Promise 对象,那么下一个 then 的链式调用会等待该 Promise 对象的状态发生变化后才会调用(能得到这个 Promise 处理的结果)。接下来重点看下 Promise 的错误处理:


const promise = new Promise<string>((resolve, reject) => {
// 下述是异常代码
console.log(a.b.c);
resolve('hello');
});

promise
.then((result) => {
console.log('result: ', result);
})
// 去掉 catch 仍然会抛出错误,但不会退出进程终止脚本执行
.catch((err) => {
// 执行
// ReferenceError: a is not defined
console.error(err);
});

setTimeout(() => {
// 继续执行
console.log('hello world!');
}, 2000);

从上述示例可以看出 Promise 的错误不会影响其他代码的执行,只会影响 Promise 内部的代码本身,因为Promise 会在内部对错误进行异常捕获,从而保证整体代码执行的稳定性。Promise 还提供了其他的一些 API 方便多任务的执行,包括



  • Promise.all:适合多个异步任务并发执行但不允许其中任何一个任务失败

  • Promise.race :适合多个异步任务抢占式执行

  • Promise.allSettled :适合多个异步任务并发执行但允许某些任务失败


Promise 相对于 Callback 对于异步的处理更加优雅,并且能力也更加强大, 但是也存在一些自身的缺点:



  • 无法取消 Promise 的执行

  • 无法在 Promise 外部通过 try...catch... 的形式进行错误捕获(Promise 内部捕获了错误)

  • 状态单一,每次决断只能产生一种状态结果,需要不停的进行链式调用



温馨提示:手写 Promise 是面试官非常喜欢的一道笔试题,本质是希望面试者能够通过底层的设计正确了解 Promise 的使用方式,如果你对 Promise 的设计原理不熟悉,可以深入了解一下或者手动设计一个。



Generator


Promise 解决了 Callback 的回调地狱问题,但也造成了代码冗余,如果一些异步任务不支持 Promise 语法,就需要进行一层 Promise 封装。Generator 将 JavaScript 的异步编程带入了一个全新的阶段,它使得异步代码的设计和执行看起来和同步代码一致。Generator 使用的简单示例如下:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

// 在 Generator 函数里执行的异步代码看起来和同步代码一致
function* gen(result: number): Generator<Promise<number>, Promise<number>, number> {
// 异步代码
const firstResult = yield firstPromise(result)
console.log('firstResult: ', firstResult) // 2
// 异步代码
const nextResult = yield nextPromise(firstResult)
console.log('nextResult: ', nextResult) // 6
return nextPromise(firstResult)
}

const g = gen(1)

// 手动执行 Generator 函数
g.next().value.then((res: number) => {
// 将 firstPromise 的返回值传递给第一个 yield 表单式对应的 firstResult
return g.next(res).value
}).then((res: number) => {
// 将 nextPromise 的返回值传递给第二个 yield 表单式对应的 nextResult
return g.next(res).value
})

通过上述代码,可以看出 Generator 相对于 Promise 具有以下优势:



  • 丰富了状态类型,Generator 通过 next 可以产生不同的状态信息,也可以通过 return 结束函数的执行状态,相对于 Promise 的 resolve 不可变状态更加丰富 

  • Generator 函数内部的异步代码执行看起来和同步代码执行一致,非常利于代码的维护

  • Generator 函数内部的执行逻辑和相应的状态变化逻辑解耦,降低了代码的复杂度


next 可以不停的改变状态使得 yield 得以继续执行的代码可以变得非常有规律,例如从上述的手动执行 Generator 函数可以看出,完全可以将其封装成一个自动执行的执行器,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>, Promise<number>, number>

function* gen(): Gen {
const firstResult = yield firstPromise(1)
console.log('firstResult: ', firstResult) // 2
const nextResult = yield nextPromise(firstResult)
console.log('nextResult: ', nextResult) // 6
return nextPromise(firstResult)
}

// Generator 自动执行器
function co(gen: () => Gen) {
const g = gen()
function next(data: number) {
const result = g.next(data)
if(result.done) {
return result.value
}
result.value.then(data => {
// 通过递归的方式处理相同的逻辑
next(data)
})
}
// 第一次调用 next 主要用于启动 Generator 函数
// 内部指针会从函数头部开始执行,直到遇到第一个 yield 表达式
// 因此第一次 next 传递的参数没有任何含义(这里传递只是为了防止 TS 报错)
next(0)
}

co(gen)



温馨提示:TJ Holowaychuk 设计了一个 Generator 自动执行器 Co,使用 Co 的前提是 yield  命令后必须是 Promise 对象或者 Thunk 函数。Co 还可以支持并发的异步处理,具体可查看官方的 API 文档



需要注意的是 Generator 函数的返回值是一个 Iterator 遍历器对象,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>>;

function* gen(): Gen {
yield firstPromise(1);
yield nextPromise(2);
}

// 注意使用 next 是继发执行,而这里是并发执行
Promise.all([...gen()]).then((res) => {
console.log('res: ', res);
});

for (const promise of gen()) {
promise.then((res) => {
console.log('res: ', res);
});
}

Generator 函数的错误处理相对复杂一些,极端情况下需要对执行和 Generator 函数进行双重错误捕获,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// 需要注意这里的reject 没有被捕获
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>>;

function* gen(): Gen {
try {
yield firstPromise(1);
yield nextPromise(2);
} catch (err) {
console.error('Generator 函数错误捕获: ', err);
}
}

try {
const g = gen();
g.next();
// 返回 Promise 后还需要通过 Promise.prototype.catch 进行错误捕获
g.next();
// Generator 函数错误捕获
g.throw('err');
// 执行器错误捕获
g.throw('err');
} catch (err) {
console.error('执行错误捕获: ', err);
}

在使用 g.throw 的时候还需要注意以下一些事项:



  • 如果 Generator 函数本身没有捕获错误,那么 Generator 函数内部抛出的错误可以在执行处进行错误捕获

  • 如果 Generator 函数内部和执行处都没有进行错误捕获,则终止进程并抛出错误信息

  • 如果没有执行过 g.next,则 g.throw 不会在 Gererator 函数中被捕获(因为执行指针没有启动 Generator 函数的执行),此时可以在执行处进行执行错误捕获


Async


Async 是 Generator 函数的语法糖,相对于 Generator 而言 Async 的特性如下:



  • 内置执行器:Generator 函数需要设计手动执行器或者通用执行器(例如 Co 执行器)进行执行,Async 语法则内置了自动执行器,设计代码时无须关心执行步骤

  • yield 命令无约束:在 Generator 中使用 Co 执行器时 yield 后必须是 Promise 对象或者 Thunk 函数,而 Async 语法中的 await 后可以是 Promise 对象或者原始数据类型对象、数字、字符串、布尔值等(此时会对其进行 Promise.resolve() 包装处理) 

  • 返回 Promise: async 函数的返回值是 Promise 对象(返回原始数据类型会被 Promise 进行封装), 因此还可以作为 await  的命令参数,相对于 Generator 返回 Iterator 遍历器更加简洁实用


举个简单的示例:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
const firstResult = await firstPromise(1);
// 1s 后打印 2
console.log('firstResult: ', firstResult);
// 等待 firstPromise 的状态发生变化后执行
const nextResult = await nextPromise(firstResult);
// 2s 后打印 6
console.log('nextResult: ', nextResult);
return nextResult;
}

co();

co().then((res) => {
console.log('res: ', res); // 6
});

通过上述示例可以看出,async 函数的特性如下:



  • 调用 async 函数后返回的是一个 Promise 对象,通过 then 回调可以拿到 async 函数内部 return 语句的返回值  

  • 调用 async 函数后返回的 Promise 对象必须等待内部所有 await 对应的 Promise 执行完(这使得 async 函数可能是阻塞式执行)后才会发生状态变化,除非中途遇到了 return 语句

  • await 命令后如果是 Promise 对象,则返回 Promise 对象处理后的结果,如果是原始数据类型,则直接返回原始数据类型


上述代码是阻塞式执行,nextPromise 需要等待 firstPromise 执行完成后才能继续执行,如果希望两者能够并发执行,则可以进行下述设计:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
return await Promise.all([firstPromise(1), nextPromise(1)]);
}

co().then((res) => {
console.log('res: ', res); // [2,3]
});

除了使用 Promise 自带的并发执行 API,也可以通过让所有的 Promise 提前并发执行来处理:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
console.log('firstPromise');
setTimeout(() => resolve(result * 2), 10000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
console.log('nextPromise');
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
// 执行 firstPromise
const first = firstPromise(1);
// 和 firstPromise 同时执行 nextPromise
const next = nextPromise(1);
// 等待 firstPromise 结果回来
const firstResult = await first;
console.log('firstResult: ', firstResult);
// 等待 nextPromise 结果回来
const nextResult = await next;
console.log('nextResult: ', nextResult);
return nextResult;
}

co().then((res) => {
console.log('res: ', res); // 3
});

Async 的错误处理相对于 Generator 会更加简单,具体示例如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Promise 决断错误
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
const firstResult = await firstPromise(1);
console.log('firstResult: ', firstResult);
const nextResult = await nextPromise(1);
console.log('nextResult: ', nextResult);
return nextResult;
}

co()
.then((res) => {
console.log('res: ', res);
})
.catch((err) => {
console.error('err: ', err); // err: 2
});

async 函数内部抛出的错误,会导致函数返回的 Promise 对象变为 rejected 状态,从而可以通过 catch 捕获, 上述代码只是一个粗粒度的容错处理,如果希望 firstPromise 错误后可以继续执行 nextPromise,则可以通过 try...catch...async 函数里进行局部错误捕获:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Promise 决断错误
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
try {
await firstPromise(1);
} catch (err) {
console.error('err: ', err); // err: 2
}

// nextPromise 继续执行
const nextResult = await nextPromise(1);
return nextResult;
}

co()
.then((res) => {
console.log('res: ', res); // res: 3
})
.catch((err) => {
console.error('err: ', err);
});


温馨提示:Callback 是 Node.js 中经常使用的编程方式,Node.js 中很多原生的 API 都是采用 Callback 的形式进行异步设计,早期的 Node.js 经常会有 Callback 和 Promise 混用的情况,并且在很长一段时间里都没有很好的支持 Async 语法。如果你对 Node.js 和它的替代品 Deno 感兴趣,可以观看 Ryan Dahl 在 TS Conf 2019 中的经典演讲 Deno is a New Way to JavaScript



31、 Object.defineProperty 有哪几个参数?各自都有什么作用?


32、 Object.defineProperty 和 ES6 的 Proxy 有什么区别?



阅读链接:基于 Vue 实现一个 MVVM - 数据劫持的实现。



33、 ES6 中 Symbol、Map、Decorator 的使用场景有哪些?或者你在哪些库的源码里见过这些 API 的使用?


34、 为什么要使用 TypeScript ? TypeScript 相对于 JavaScript 的优势是什么?


35、 TypeScript 中 const 和 readonly 的区别?枚举和常量枚举的区别?接口和类型别名的区别?


36、 TypeScript 中 any 类型的作用是什么?


37、 TypeScript 中 any、never、unknown 和 void 有什么区别?


38、 TypeScript 中 interface 可以给 Function / Array / Class(Indexable)做声明吗?


39、 TypeScript 中可以使用 String、Number、Boolean、Symbol、Object 等给类型做声明吗?


40、 TypeScript 中的 this 和 JavaScript 中的 this 有什么差异?


41、 TypeScript 中使用 Unions 时有哪些注意事项?


42、 TypeScript 如何设计 Class 的声明?


43、 TypeScript 中如何联合枚举类型的 Key?


44、 TypeScript 中 ?.、??、!.、_、** 等符号的含义?


45、 TypeScript 中预定义的有条件类型有哪些?


46、 简单介绍一下 TypeScript 模块的加载机制?


47、 简单聊聊你对 TypeScript 类型兼容性的理解?抗变、双变、协变和逆变的简单理解?


48、 TypeScript 中对象展开会有什么副作用吗?


49、 TypeScript 中 interface、type、enum 声明有作用域的功能吗?


50、 TypeScript 中同名的 interface 或者同名的 interface 和 class 可以合并吗?


51、 如何使 TypeScript 项目引入并识别编译为 JavaScript 的 npm 库包?


52、 TypeScript 的 tsconfig.json 中有哪些配置项信息?


53、 TypeScript 中如何设置模块导入的路径别名?



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

收起阅读 »

前端面试知识点(一)

基础知识 基础知识主要包含以下几个方面: 基础:计算机原理、编译原理、数据结构、算法、设计模式、编程范式等基本知识了解 语法: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

收起阅读 »