JavaScript深入之new的模拟实现

new

一句话介绍 new:

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象类型之一

也许有点难懂,我们在模拟 new 之前,先看看 new 实现了哪些功能。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Otaku 御宅族,简称宅
function Otaku (name, age) {
this.name = name;
this.age = age;

this.habit = 'Games';
}

// 因为缺乏锻炼的缘故,身体强度让人担忧
Otaku.prototype.strength = 60;

Otaku.prototype.sayYourName = function () {
console.log('I am ' + this.name);
}

var person = new Otaku('Kevin', '18');

console.log(person.name) // Kevin
console.log(person.habit) // Games
console.log(person.strength) // 60

person.sayYourName(); // I am Kevin

从这个例子中,我们可以看到,实例 person 可以:

  1. 访问到 Otaku 构造函数里的属性
  2. 访问到 Otaku.prototype 中的属性

接下来,我们可以尝试着模拟一下了。

因为 new 是关键字,所以无法像 bind 函数一样直接覆盖,所以我们写一个函数,命名为 objectFactory,来模拟 new 的效果。用的时候是这样的:

1
2
3
4
5
6
7
8
function Otaku () {
……
}

// 使用 new
var person = new Otaku(……);
// 使用 objectFactory
var person = objectFactory(Otaku, ……)

初步实现

分析:

因为 new 的结果是一个新对象,所以在模拟实现的时候,我们也要建立一个新对象,假设这个对象叫 obj,因为 obj 会具有 Otaku 构造函数里的属性,想想经典继承的例子,我们可以使用 Otaku.apply(obj, arguments) 来给 obj 添加新的属性。

在 JavaScript 深入系列第一篇中,我们便讲了原型与原型链,我们知道实例的 proto 属性会指向构造函数的 prototype,也正是因为建立起这样的关系,实例可以访问原型上的属性。

现在,我们可以尝试着写第一版了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 第一版代码
function objectFactory() {

var obj = new Object(),

Constructor = [].shift.call(arguments);

obj.__proto__ = Constructor.prototype;

Constructor.apply(obj, arguments);

return obj;

};

在这一版中,我们:

  1. 用 new Object() 的方式新建了一个对象 obj
  2. 取出第一个参数,就是我们要传入的构造函数。此外因为 shift 会修改原数组,所以 arguments 会被去除第一个参数
  3. 将 obj 的原型指向构造函数,这样 obj 就可以访问到构造函数原型中的属性
  4. 使用 apply,改变构造函数 this 的指向到新建的对象,这样 obj 就可以访问到构造函数中的属性
  5. 返回 obj

更多关于:

原型与原型链,可以看《JavaScript 深入之从原型到原型链》

apply,可以看《JavaScript 深入之 call 和 apply 的模拟实现》

经典继承,可以看《JavaScript 深入之继承》

复制以下的代码,到浏览器中,我们可以做一下测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function Otaku (name, age) {
this.name = name;
this.age = age;

this.habit = 'Games';
}

Otaku.prototype.strength = 60;

Otaku.prototype.sayYourName = function () {
console.log('I am ' + this.name);
}

function objectFactory() {
var obj = new Object(),
Constructor = [].shift.call(arguments);
obj.__proto__ = Constructor.prototype;
Constructor.apply(obj, arguments);
return obj;
};

var person = objectFactory(Otaku, 'Kevin', '18')

console.log(person.name) // Kevin
console.log(person.habit) // Games
console.log(person.strength) // 60

person.sayYourName(); // I am Kevin

[]**

返回值效果实现

接下来我们再来看一种情况,假如构造函数有返回值,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Otaku (name, age) {
this.strength = 60;
this.age = age;

return {
name: name,
habit: 'Games'
}
}

var person = new Otaku('Kevin', '18');

console.log(person.name) // Kevin
console.log(person.habit) // Games
console.log(person.strength) // undefined
console.log(person.age) // undefined

在这个例子中,构造函数返回了一个对象,在实例 person 中只能访问返回的对象中的属性。

而且还要注意一点,在这里我们是返回了一个对象,假如我们只是返回一个基本类型的值呢?

再举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Otaku (name, age) {
this.strength = 60;
this.age = age;

return 'handsome boy';
}

var person = new Otaku('Kevin', '18');

console.log(person.name) // undefined
console.log(person.habit) // undefined
console.log(person.strength) // 60
console.log(person.age) // 18

结果完全颠倒过来,这次尽管有返回值,但是相当于没有返回值进行处理。

所以我们还需要判断返回的值是不是一个对象,如果是一个对象,我们就返回这个对象,如果没有,我们该返回什么就返回什么。

再来看第二版的代码,也是最后一版的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 第二版的代码
function objectFactory() {

var obj = new Object(),

Constructor = [].shift.call(arguments);

obj.__proto__ = Constructor.prototype;

var ret = Constructor.apply(obj, arguments);

return typeof ret === 'object' ? ret : obj;

};

CSS 变量及其应用

基本介绍

自定义属性 (有时候也被称作CSS 变量或者级联变量)是由 CSS 作者定义的,它包含的值可以在整个文档中重复使用。由自定义属性标记设定值(比如: **--main-color: black;**),由var() 函数来获取值(比如: color: **var(--main-color)**;

复杂的网站都会有大量的 CSS 代码,通常也会有许多重复的值。举个例子,同样一个颜色值可能在成千上百个地方被使用到,如果这个值发生了变化,需要全局搜索并且一个一个替换(很麻烦哎~)。自定义属性在某个地方存储一个值,然后在其他许多地方引用它。另一个好处是语义化的标识。比如,--main-text-color 会比 #00ff00 更易理解,尤其是这个颜色值在其他上下文中也被使用到。自定义属性受级联的约束,并从其父级继承其值。

与 sass less 变量的区别

  • sass 命名是$color,less 命名是@color,css 命名是–color。
  • 读取 css 变量,需要使用 val()方法,sass 和 less 可以直接使用。
  • css 最大优势在于不需要编译,在运行时可以随时修改,同时应用到上下文。缺点是不兼容 ie 浏览器。

image.png

基本用法

声明一个自定义属性,属性名需要以两个减号(--)开始,属性值则可以是任何有效的 CSS 值。和其他属性一样,自定义属性也是写在规则集之内的,如下:

1
2
3
element {
--main-bg-color: brown;
}

注意,规则集所指定的选择器定义了自定义属性的可见作用域。通常的最佳实践是定义在根伪类 :root 下,这样就可以在 HTML 文档的任何地方访问到它了:

1
2
3
:root {
--main-bg-color: brown;
}

然而这条规则不是绝对的,如果有理由去限制你的自定义属性,那么就应该限制。
*
**注意:\
*自定义属性名是大小写敏感的,--my-color--My-color 会被认为是两个不同的自定义属性。
如前所述,使用一个局部变量时用 var() 函数包裹以表示一个合法的属性值:

1
2
3
element {
background-color: var(--main-bg-color);
}

使用自定义属性的第一步

我们从这个简单的 CSS 代码开始,它将相同的颜色应用在了不同 class 的元素上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.one {
color: white;
background-color: brown;
margin: 10px;
width: 50px;
height: 50px;
display: inline-block;
}
.two {
color: white;
background-color: black;
margin: 10px;
width: 150px;
height: 70px;
display: inline-block;
}
.three {
color: white;
background-color: brown;
margin: 10px;
width: 75px;
}
.four {
color: white;
background-color: brown;
margin: 10px;
width: 100px;
}
.five {
background-color: brown;
}

应用在如下 HTML 上:

1
2
3
4
5
6
<div>
<div class="one">1:</div>
<div class="two">2: Text <span class="five">5 - more text</span></div>
<input class="three" />
<textarea class="four">4: Lorem Ipsum</textarea>
</div>

其呈现是:
注意到在 CSS 代码中的重复:背景色 brown 被多处设置。对于一些 CSS 声明,是可以在级联关系更高的位置设置,通过 CSS 继承自然地解决这个重复的问题。但在一般项目中,是不可能通过这样的方式去解决。通过在 :root 伪类上设置自定义属性,然后在整个文档需要的地方使用,可以减少这样的重复性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
:root {
--main-bg-color: brown;
}
.one {
color: white;
background-color: var(--main-bg-color);
margin: 10px;
width: 50px;
height: 50px;
display: inline-block;
}
.two {
color: white;
background-color: black;
margin: 10px;
width: 150px;
height: 70px;
display: inline-block;
}
.three {
color: white;
background-color: var(--main-bg-color);
margin: 10px;
width: 75px;
}
.four {
color: white;
background-color: var(--main-bg-color);
margin: 10px;
width: 100px;
}
.five {
background-color: var(--main-bg-color);
}

这里呈现的结果和前面的例子是一致的,但允许对所需属性值进行一个规范的声明。

自定义属性的继承性

自定义属性会继承。这意味着如果在一个给定的元素上,没有为这个自定义属性设置值,在其父元素上的值会被使用。看这一段 HTML:

1
2
3
4
5
6
<div class="one">
<div class="two">
<div class="three"></div>
<div class="four"></div>
</div>
</div>

配套的 CSS:

1
2
3
4
5
6
.two {
--test: 10px;
}
.three {
--test: 2em;
}

在这个情况下, var(--test) 的结果分别是:

  • 对于元素 class="two"10px
  • 对于元素 class="three"2em
  • 对于元素 class="four"10px (继承自父属性)
  • 对于元素 class="one" :_非法值_,会变成自定义属性的默认值

注意,这些是自定义属性,并不是你在其他编程语言中遇到的实际的变量。这些值仅当需要的时候才会计算,而并不会按其他规则进行保存。比如,你不能为元素设置一个属性,然后让它从兄弟或旁支子孙规则上获取值。属性仅用于匹配当前选择器及其子孙,这和通常的 CSS 是一样的。

自定义属性备用值

var() 函数可以定义多个备用值(fallback value),当给定值未定义时将会用备用值替换。这对于 Custom ElementsShadow DOM 都很有用。
备用值并不是用于实现浏览器兼容性的。如果浏览器不支持 CSS 自定义属性,备用值也没什么用。它仅对支持 CSS 自定义属性的浏览器提供了一个备份机制,该机制仅当给定值未定义或是无效值的时候生效。
函数的第一个参数是自定义属性的名称。如果提供了第二个参数,则表示备用值,当自定义属性值无效时生效。第二个参数可以嵌套,但是不能继续平铺展开下去了,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.two {
color: var(--my-var, red); /* Red if --my-var is not defined */
}
.three {
background-color: var(
--my-var,
var(--my-background, pink)
); /* pink if --my-var and --my-background are not defined */
}
.three {
background-color: var(
--my-var,
--my-background,
pink
); /* Invalid: "--my-background, pink" */
}

第二个例子展示了如何处理一个以上的 fallback。该技术可能会导致性能问题,因为它花了更多的时间在处理这些变量上。
注意:自定义属性这些 fallback 语法允许使用逗号。比如 var(--foo, red, blue) 定义了一个 red, blue 的备用值——从第一个逗号到最后的全部内容,都会被作为备用值的一部分。

有效性和值

传统的 CSS 概念里,有效性和属性是绑定的,这对自定义属性来说并不适用。当自定义属性值被解析,浏览器不知道它们什么时候会被使用,所以必须认为这些值都是有效的
不幸的是,即便这些值是有效的,但当通过 var() 函数调用时,它在特定上下文环境下也可能不会奏效。属性和自定义变量会导致无效的 CSS 语句,这引入了一个新的概念:_计算时有效性_。

无效变量会导致什么?

当浏览器遇到无效的 var() 时,会使用继承值或初始值代替。
考虑如下代码:

1
<p>This paragraph is initial black.</p>
1
2
3
4
5
6
7
8
9
:root {
--text-color: 16px;
}
p {
color: blue;
}
p {
color: var(--text-color);
}

毫不意外,浏览器将 --text-color 的值替换给了 var(--text-color),但是 16px 并不是 color 的合法属性值。代换之后,该属性不会产生任何作用。浏览器会执行如下两个步骤:

  1. 检查属性 color 是否为继承属性。是,但是 <p> 没有任何父元素定义了 color 属性。转到下一步。
  2. 将该值设置为它的默认初始值,比如 black。Result


段落颜色并不是蓝色,因为无效代换导致了它被替换成了默认初始值的黑色。如果你直接写 n color: 16px 的话,则会导致语法错误,而前面的定义则会生效(段落显示为蓝色)。
注意:当 CSS 属性-值对中存在语法错误,该行则会被忽略。然而如果自定义属性的值无效,它并不会被忽略,从而会导致该值被覆盖为默认值。

JavaScript 中的值

在 JavaScript 中获取或者修改 CSS  变量和操作普通 CSS 属性是一样的:

1
2
3
4
5
6
// 获取一个 Dom 节点上的 CSS 变量
element.style.getPropertyValue("--my-var");
// 获取任意 Dom 节点上的 CSS 变量
getComputedStyle(element).getPropertyValue("--my-var");
// 修改一个 Dom 节点上的 CSS 变量
element.style.setProperty("--my-var", jsVar + 4);

应用:全局样式,定义主题,快速切换。

实现黑夜模式

深色模式为目前网络发展的一大趋势,可以看到大量的网站为了提高网站的体验都添加了深色模式。深色模式在光线不足的情况下看起来不会那么刺眼,能够很好的保护我们的眼睛。
在这边文章中主要讲如何使用 CSS 和 JS 实现深色模式和浅色模式的任意切换

分析需求

假设有这么一个页面,我们需要自由切换深色模式和浅色模式。那么就需要在不同模式使用不同的 css,这里可以通过两种方式一种是直接引入不同的 css 文件,另外一种通过更改 css 变量值的方式进行更改样式,下面是浅色模式的截图

具体实现

首先定义浅色模式的变量名和变量值

1
2
3
4
5
6
7
8
9
10
11
:root {
--primary-bg: #eee;
--primary-fg: #000;
--secondary-bg: #ddd;
--secondary-fg: #555;
--primary-btn-bg: #000;
--primary-btn-fg: #fff;
--secondary-btn-bg: #ff0000;
--secondary-btn-fg: #ffff00;
}
复制代码

当切换场景的时候需要更改 css 变量的值,更改如下:

1
2
3
4
5
6
7
8
9
10
11
:root {
--primary-bg: #282c35;
--primary-fg: #fff;
--secondary-bg: #1e2129;
--secondary-fg: #aaa;
--primary-btn-bg: #ddd;
--primary-btn-fg: #222;
--secondary-btn-bg: #780404;
--secondary-btn-fg: #baba6a;
}
复制代码

可以看到当切换到深色模式的时候,变量使用了更加暗的颜色,从而实现深色模式

更改 css

如何切换到暗模式有多种解决方法,在这里我们使用媒体查询,prefers-color-scheme这个媒体查询能够获取到用户的系统是否切换到了深色主题,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@media (prefers-color-scheme: dark) {
:root {
--primary-bg: #282c35;
--primary-fg: #fff;
--secondary-bg: #1e2129;
--secondary-fg: #aaa;
--primary-btn-bg: #ddd;
--primary-btn-fg: #222;
--secondary-btn-bg: #780404;
--secondary-btn-fg: #baba6a;
--image-opacity: 0.85;
}
}
复制代码

如果希望用户可以通过选择系统的设置来切换浅色模式还是深色模式,那么上面这种方式就足够了。浏览的网站能够通过系统设置选择不同的样式
但是上面这种方式存在一个问题,就是用户希望这个页面的模式不要跟随系统配置的更改而更改。用户可以主动更改网站的模式,那么上面这种方式就不合适了

手动选择模式

思路就是通过控制 js 来给元素添加不同的 class,不同的 class 拥有不同的样式。首先添加在 html 中添加一个按钮用于切换不同的模式

1
2
3
4
5
<button id="toggle-button">toggle</button>
<script>
const toggleButton = document.querySelector("#toggle-button");
</script>
复制代码

然后需要地方存储用户的偏好设置,这里使用 localStorage 来存储用户的选择。
然后给按钮添加事件用于切换主题,下面是具体的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const toggleButton = document.querySelector("#toggle-button");

toggleButton.addEventListener("click", (e) => {
darkMode = localStorage.getItem("theme");
if (darkMode === "dark") {
disableDarkMode();
} else {
enableDarkMode();
}
});
function enableDarkMode() {
localStorage.setItem("theme", "dark");
}
function disableDarkMode() {
localStorage.setItem("theme", "light");
}
复制代码;

现在我们就可以存储这个用户的偏好设置。然后不同的主题下给 body 元素添加不同的class,具体如下

1
2
3
4
5
6
7
8
9
function enableDarkMode() {
document.body.classList.add("dark-mode");
localStorage.setItem("theme", "dark");
}
function disableDarkMode() {
document.body.classList.remove("dark-mode");
localStorage.setItem("theme", "light");
}
复制代码;

和媒体查询一样,在dark-mode的情况下更改 css 变量的属性值,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
.dark-mode {
--primary-bg: #282c35;
--primary-fg: #fff;
--secondary-bg: #1e2129;
--secondary-fg: #aaa;
--primary-btn-bg: #ddd;
--primary-btn-fg: #222;
--secondary-btn-bg: #780404;
--secondary-btn-fg: #baba6a;
--image-opacity: 0.85;
}
复制代码

同时在进入这个页面的时候需要获取到用户的偏好设置,从 localStorage 中读取

1
2
3
let darkMode = localStorage.getItem("theme");
if (darkMode === "dark") enableDarkMode();
复制代码;

这次就可以在页面刷新以后仍然拿到用户的偏好设置。


事件循环和同步异步

某日,群里有人发了一张这样的图,问输出结果为什么和预期的不一样,有几个人在讨论,都说不出个为什么。这一看就是一道面试题,考察了js的同步异步和事件循环,当然工作中类似的场景也非常多,这道题对于理解同类的问题会有一定的帮助。这里就这道题,解析一下它是如何执行的。在继续看下去之前,可以把你的答案先记下来。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}

async function async2() {
console.log('async2')
}

console.log('script start')
setTimeout(function() {
console.log('setTimeout')
}, 0)

async1();

new Promise( function( resolve ) {
console.log('promise1')
resolve();
} ).then( function() {
console.log('promise2')
} )

console.log('script end')

async

要理解这道题,首先要理解es2017 async 语法。

  • async 函数返回promise对象,后面可以直接接then。
  • 语法规定,async 的 await 命令后面,可以接promise对象或者原始类型的值(但是会转成立即resolve的promise对象。Promise.resolve())。

async的基本用法

async函数返回一个 Promise 对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。


下面是一个例子。函数前面的async关键字,表明该函数内部有异步操作。调用该函数时,会立即返回一个Promise对象。

1
2
3
4
5
6
7
8
9
async function getStockPriceByName(name) {
const symbol = await getStockSymbol(name);
const stockPrice = await getStockPrice(symbol);
return stockPrice;
}

getStockPriceByName('goog').then(function (result) {
console.log(result);
});


举个栗子,async3() 返回了promise对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 预期输出
// res
// undefined
async function async3() {
console.log('res');
}
async3().then((res) => { console.log(res); })



// 类似async1里面的操作,但是这里没有await关键字,所以不会阻塞
// console.log('async1 start')
// await async2()
// console.log('async1 end')
console.log(1);
Promise.resolve();
console.log(2);
// 输出
// 1
// 2
// undefined

event loops

event loops是什么

Javascript是单线程的语言,既然是单线程的,在某个特定的时刻只有特定的代码能够被执行,并阻塞其它的代码。 而浏览器是事件驱动的(Event driven),浏览器中很多行为是异步(Asynchronized)的,会创建事件并放入执行队列中,按照一定的顺序进行执行。

event loops的任务类型

  • 宏任务
    • setTimeout
    • setInterval
    • requestAnimationFrame
    • 解析HTML
    • 执行主线程JS代码
    • 修改URL
    • 页面加载
    • 用户交互
    • 。。。
  • 微任务
    • Promise
    • MutationObserver
    • process.nextTick (nodejs)
    • queueMicrotask

#### event loops的执行顺序(浏览器)
  1. 第一步,检查macrotask队列,运行最前面的任务,如果队列为空,前往第二步。
  2. 第二步,检查microtask队列,一直运行该队列任务直到该队列为空。
  3. 第三步,执行渲染ui。渲染后回到第一步。

执行过程解析

理解基本async语法和event loops机制后,可以写出执行过程如下

  • 定义了async函数 async1
  • 定义了async函数 async2
  • 输出 script start
  • 定义了一个setTimeout,该异步操作属于宏任务,推入macrotask队列
  • 执行到了async1,进入async1
  • 输出 async1 start
  • 执行到了await语句,先执行async2(),输出 async2, async2()返回了一个promise对象,这个promise被推入microtask队列,await会阻塞当前执行函数体后续的语句,等待这个异步promise完成。
  • 代码跳出async1继续执行
  • 进入new Promise同步执行部分,输出 promise1
  • 执行resolve,异步回调推入推入microtask队列
  • 输出 script end
  • 从microtask队列取出第一个异步任务执行,这个promise对象的resolve返回值为 undefined
  • 继续执行async1里await后面的语句,输出 async1 end
  • 从microtask队列取出第二个异步任务执行,输出 promise2
  • 当前macrotask执行完毕
  • 取出下一个macrotask执行,输出 setTimeout


最终输出结果
  1. script start
  2. async1 start
  3. async2
  4. promise1
  5. script end
  6. async1 end
  7. promise2
  8. setTimeout

JS常用实现(一)

debounce(防抖)

防抖可以让多个顺序调用的事件合成一个,防止抖动,防止正常需要一次执行就可以的事件执行了多次。在用户停止触发事件的时候,才进行执行事件。


例如在搜索引擎输入框中输入文字时,每输入一个键盘字符,搜索框会实时将输入值通过请求发送到后台。将每个用户输入的字符都发送至后台,会导致请求过于频繁,造成资源的浪费。


同时用户的体验也不佳,每输入一个字符都发送请求,对于服务器的性能要求很高,因为如果请求耗时太长,就无法做到每敲一个字符,就马上出来关联的输入提示内容,需要很优秀的服务器性能来配合实现。所以一般来说,这样的输入框都需要用防抖来进行处理。除非数据检索或者代码执行耗时足够短,可以不做处理。


同样适用的场景有浏览器的大小调整resize监听,滚动条滚动scroll监听,按钮点击click事件等。
image.png

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 防抖
* @param func 处理函数
* @param wait 等待时长,毫秒
*/
const debounce = (func, wait) => {
let timeout = null;
return () => {
clearTimeout(timeout);
timeout = setTimeout(func, wait);
}
}
1
2
3
/** 测试用例 */
const testDebounce = (e) => { console.log(e) }
document.addEventListener('scroll', debounce(testDebounce, 300));

这里为什么要return一个函数,因为timeout变量存在于debounce局部作用域内,正常调用后timeout变量会被内存回收。返回一个函数,引用了timeout变量,形成了闭包,所以在返回的函数内部可以读取到局部timeout变量。

throttle(节流)

节流,也可以理解为限流。让事件在每个间隔的时间里,只执行一次。和防抖不同,节流保证了在x毫秒的事件内,必然触发一次事件。


例如地铁上下班时间限流,每隔5分钟才能进站一波人,这就是限流。防抖和节流,作用区别不大,在应用场景上有一定的重合,关键在于是否需要在一段时间内必须触发一次事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 节流,结合了防抖的例子
* @param func 处理函数
* @param wait 等待时长,毫秒
* @param mustRun 间隔时长,毫秒
*/
const throttle = (func, wait, mustRun) => {
let timeout = null;
let startTime = new Date();
return (...args) => {
const curTime = new Date();
clearTimeout(timeout);
if (curTime - startTime >= mustRun) {
func.apply(this, args);
startTime = curTime;
} else {
timeout = setTimeout(func, wait);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 节流,不使用定时器。previous变量不会被销毁,所以根据节流的特性可以这样直接调用。
* @param func 处理函数
* @param mustRun 间隔时长,毫秒
*/
const throttle = (func, mustRun) => {
let previous = 0;
return (...args) => {
const curTime = new Date();
if (curTime - previous >= mustRun) {
func.apply(this, args);
previous = curTime;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 节流,使用定时器
* @param func 处理函数
* @param wait 间隔时长,毫秒
*/
const throttle = (func, mustRun) => {
let timeout = null;
return (...args) => {
if (timeout) {
return;
}
timeout = setTimeout(() => {
func.apply(this, args);
timeout = null;
}, mustRun);
}
}

注意

1
2
3
/** 测试用例 */
const test = (e) => { console.log(e) }
document.addEventListener('scroll', throttle(test, 300));

new(new运算符)

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。
语法 new constructor[([arguments])]


new是创建一个新实例,新实例肯定是一个对象。


New操作符实际上经历以下4个步骤:

  1. 创建一个新对象
  2. 将构造函数的作用域赋给新对象(this指向新对象)
  3. 执行构造函数中的代码(为新对象添加属性)
  4. 返回新对象,如果是引用类型,返回这个引用类型对象,否则返回新创建对象


new 主要来新建实例,要理解new首先来对JS原型链有一定的认识。JS原型prototype在父类里是protoype,在实例里是[[prototype]],可以通过__proto__访问。因此创建一个新对象,同时将新对象的__proto__设置为父类的prototype,实现继承父类。


例如:

1
2
3
class A {};
const a = new A();
a.__proto__ === A.prototype; // true
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 模拟实现new
* @param parentObject 父类
* @param args 父类构造函数参数
*/
const newObject = (parentObject, ...args) => {
// 绑定原型
const newObj= Object.create(parentObject.prototype); // 使用create方法设置新对象__proto__
// 调用构造函数
const result = parentObject.apply(newObj, args);
// 返回
return result instanceof Object ? result : newObj;
}

deepClone (深拷贝)

网上的深拷贝代码一般都有些问题的,因为对于一些特殊的对象没有进行处理,但是一般也不会出现bug,简单的深拷贝有时也能实现功能。


对象有可能出现循环引用。

1
2
3
const a = { name: 'ben' };
const b = { a };
a.b = b;

结构如下:
image.png


实现一 JSON.stringify && JSON.parse
通过JSON的两个方法使对象重新构造成新的对象实现深拷贝,它的问题在于会丢弃对象的constructor,也不支持循环引用。同时必须保证处理的对象为能够被json数据结构表示,不符合转换规则会抛出错误。

不推荐这种实现,如果要用,需要配合错误捕获方法来使用。

1
2
const a = {};
const b = JSON.parse(JSON.stringify(a));


实现二 递归
存在问题,支持的特殊对象不是很多,但是还算优雅,解决了循环引用的问题,同时遇到已经引用过的对象,不再重复循环一遍。可以解决循环引用问题,因为遇到相同对象会从cache里直接拿出来返回,并且拿出来的是已经处理过的对象,不进入循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const deepClone = (obj, cache = new WeakMap()) => {
if (!obj instanceof Object) return obj;
if (cache.get(obj)) return cache.get(obj); // 防止循环引用
if (obj instanceof Function) {
return (...arg) => { obj.apply(this, args) }
} // 支持函数
if (obj instanceof Date) return new Date(obj); // 支持日期
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags); // 支持正则对象

const res = Array.isArray(obj) ? [] : {};
cache.set(obj, res); // 缓存 copy 的对象,用于处理循环引用的情况

Object.keys(obj).forEach((key) => {
if (obj[key] instanceof Object) {
res[key] = deepClone(obj[key], cache);
} else {
res[key] = obj[key];
}
});

return res;
}


测试用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 执行解释

// 执行deepClone(a);
// 'a对应对象'本身不在缓存里,将res设置为缓存,此时初始res = {}; 初始res同时也是递归结束后要返回的对象。
// 进入循环
// key等于name,不在缓存里,设置缓存。继续执行,不是对象,直接返回。此时初始res = {name: 'ben'};
// key等于b,不在缓存里,设置缓存。继续执行,b值是对象。进入下一层循环。此时初始res = {name: 'ben'}; b的res是: {}
// key等于a,在缓存里,拿出来返回。b的res是: { a: 初始res }。循环结束,函数返回b的res。 此时初始res = {name: 'ben', b: { a: 初始res }};
// 循环结束,返回初始res {name: 'ben', b: { a: 初始res }}

// 注意!当时设置'a对应对象'的缓存值是新构造的res对象,所以新拿出来的'a对应对象'的缓存值,是重新构造后的值,并不是原始的值。
// 变成了新的克隆对象循环引用构造后的它自己本身的值。

const a = { name: 'ben' };
const b = { a };
a.b = b;

const deepClone = (obj, cache = new WeakMap()) => {
if (!obj instanceof Object) return obj;
if (cache.get(obj)) return cache.get(obj); // 防止循环引用
if (obj instanceof Function) {
return (...arg) => { obj.apply(this, args) }
} // 支持函数
if (obj instanceof Date) return new Date(obj); // 支持日期
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags); // 支持正则对象

const res = Array.isArray(obj) ? [] : {};
cache.set(obj, res); // 缓存 copy 的对象,用于处理循环引用的情况

Object.keys(obj).forEach((key) => {
console.log(key);
if (obj[key] instanceof Object) {
res[key] = deepClone(obj[key], cache);
} else {
res[key] = obj[key];
}
});

console.log(res, 'res');
return res;
}

const c = deepClone(a);
console.log(a.b.a.b.a.b === c.b.a.b.a.b); // false

a.age = 1;
c.age = 2;

console.log(a);
console.log(c);


输出
image.png

Vue性能提升之Object.freeze()

在 Vue 的文档中介绍数据绑定和响应时,特意标注了对于经过 Object.freeze() 方法的对象无法进行更新响应。因此,特意去查了 Object.freeze() 方法的具体含义。

含义

Object.freeze() 方法用于冻结对象,禁止对于该对象的属性进行修改(由于数组本质也是对象,因此该方法可以对数组使用)。在 Mozilla MDN 中是如下介绍的:

可以冻结一个对象。一个被冻结的对象再也不能被修改;冻结了一个对象则不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值。此外,冻结一个对象后该对象的原型也不能被修改

该方法的返回值是其参数本身。

需要注意的是以下两点

  1. Object.freeze() 和 const 变量声明不同,也不承担 const 的功能。

    const 和 Object.freeze() 完全不同

  • const 的行为像 let。它们唯一的区别是, const 定义了一个无法重新分配的变量。 通过 const 声明的变量是具有块级作用域的,而不是像 var 声明的变量具有函数作用域。
  • Object.freeze() 接受一个对象作为参数,并返回一个相同的不可变的对象。这就意味着我们不能添加,删除或更改对象的任何属性。
  • const 和 Object.freeze() 并不同,const 是防止变量重新分配,而 Object.freeze() 是使对象具有不可变性。

以下代码是正确的:

  1. Object.freeze() 是 “浅冻结”,以下代码是生效的:

实例

常规用法

明显看到,a 的 prop 属性未被改变,即使重新赋值了。

延伸

“深冻结”

要完全冻结具有嵌套属性的对象,您可以编写自己的库或使用已有的库来冻结对象,如Deepfreezeimmutable-js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 深冻结函数.
function deepFreeze(obj) {

// 取回定义在obj上的属性名
var propNames = Object.getOwnPropertyNames(obj);

// 在冻结自身之前冻结属性
propNames.forEach(function(name) {
var prop = obj[name];

// 如果prop是个对象,冻结它
if (typeof prop == 'object' && prop !== null)
deepFreeze(prop);
});

// 冻结自身(no-op if already frozen)
return Object.freeze(obj);
}

其实就是个简单的递归方法。但是涉及到一个很重要,但是在写业务逻辑的时候很少用的知识点 Object.getOwnPropertyNames(obj) 。我们都知道在 JS 的 Object 中存在原型链属性,通过这个方法可以获取所有的非原型链属性。

利用Object.freeze()提升性能

除了组件上的优化,我们还可以对 vue 的依赖改造入手。初始化时,vue 会对 data 做 getter、setter 改造,在现代浏览器里,这个过程实际上挺快的,但仍然有优化空间。

Object.freeze() 可以冻结一个对象,冻结之后不能向这个对象添加新的属性,不能修改其已有属性的值,不能删除已有属性,以及不能修改该对象已有属性的可枚举性、可配置性、可写性。该方法返回被冻结的对象。

当你把一个普通的 JavaScript 对象传给 Vue 实例的  data  选项,Vue 将遍历此对象所有的属性,并使用  Object.defineProperty  把这些属性全部转为 getter/setter,这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 追踪依赖,在属性被访问和修改时通知变化。

但 Vue 在遇到像 Object.freeze() 这样被设置为不可配置之后的对象属性时,不会为对象加上 setter getter 等数据劫持的方法。参考 Vue 源码

Vue observer 源码

性能提升效果对比

在基于 Vue 的一个 big table benchmark 里,可以看到在渲染一个一个 1000 x 10 的表格的时候,开启Object.freeze() 前后重新渲染的对比。

big table benchmark

开启优化之前

开启优化之后

在这个例子里,使用了 Object.freeze()比不使用快了 4 倍

为什么Object.freeze() 的性能会更好

不使用Object.freeze() 的 CPU 开销

使用 Object.freeze()的 CPU 开销

对比可以看出,使用了 Object.freeze() 之后,减少了 observer 的开销。

Object.freeze()应用场景

由于 Object.freeze()会把对象冻结,所以比较适合展示类的场景,如果你的数据属性需要改变,可以重新替换成一个新的 Object.freeze()的对象。

Javascript 对象解冻

修改 React props React 生成的对象是不能修改 props 的, 但实践中遇到需要修改 props 的情况. 如果直接修改, js 代码将报错, 原因是 props 对象被冻结了, 可以用 Object.isFrozen() 来检测, 其结果是 true. 说明该对象的属性是只读的.

那么, 有方法将 props 对象解冻, 从而进行修改吗?

事实上, 在 javascript 中, 对象冻结后, 没有办法再解冻, 只能通过克隆一个具有相同属性的新对象, 通过修改新对象的属性来达到目的.

可以这样:

1
2
ES6: Object.assign({}, frozenObject);
lodash: _.assign({}, frozenObject);

来看实际代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function modifyProps(component) {
let condictioin = this.props.condictioin,
newComponent = Object.assign({}, component),
newProps = Object.assign({}, component.props)

if (condictioin) {
if (condictioin.add) newProps.add = true
if (condictioin.del) newProps.del = true
}
newComponent.props = newProps

return newComponent
}

锁定对象的方法

  • Object.preventExtensions()

no new properties or methods can be added to the project 对象不可扩展, 即不可以新增属性或方法, 但可以修改 / 删除

  • Object.seal()

same as prevent extension, plus prevents existing properties and methods from being deleted 在上面的基础上,对象属性不可删除, 但可以修改

  • Object.freeze()

same as seal, plus prevent existing properties and methods from being modified 在上面的基础上,对象所有属性只读, 不可修改

以上三个方法分别可用 Object.isExtensible(), Object.isSealed(), Object.isFrozen() 来检测

Object.freeze( ) 阻止 Vue 无法实现 响应式系统

当一个 Vue 实例被创建时,它向 Vue 的响应式系统中加入了其 data 对象中能找到的所有的属性。当这些属性的值发生改变时,视图将会产生 “响应”,即匹配更新为新的值。但是如果使用 Object.freeze(),这会阻止修改现有的属性,也意味着响应系统无法再追踪变化。

具体使用办法举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<template>
<div>
<p>freeze后会改变吗
{{obj.foo}}
</p>
<!-- 两个都不能修改??为什么?第二个理论上应该是可以修改的-->
<button @click="change">点我确认</button>
</div>
</template>

<script>
var obj = {
foo: '不会变'
}
Object.freeze(obj)
export default {
name: 'index',
data () {
return {
obj: obj
}
},
methods: {
change () {
this.obj.foo = '改变'
}
}
}
</script>

运行后:

从报错可以看出只读属性 foo 不能进行修改,Object.freeze() 冻结的是值,你仍然可以将变量的引用替换掉, 将上述代码更改为:

1
2
3
4
5
6
7
<button @click="change">点我确认</button>

change () {
this.obj = {
foo: '会改变'
}
}

Object.freeze() 是 ES5 新增的特性,可以冻结一个对象,冻结指的是不能向这个对象添加新的属性,不能修改其已有属性的值,不能删除已有属性,以及不能修改该对象已有属性的可枚举性、可配置性、可写性。防止对象被修改。 如果你有一个巨大的数组或 Object,并且确信数据不会修改,使用 Object.freeze() 可以让性能大幅提升。

实践心得和技巧

Object.freeze() 是 ES5 新增的特性,可以冻结一个对象,防止对象被修改。

vue 1.0.18 + 对其提供了支持,对于 data 或 vuex 里使用 freeze 冻结了的对象,vue 不会做 getter 和 setter 的转换。

如果你有一个巨大的数组或 Object,并且确信数据不会修改,使用 Object.freeze() 可以让性能大幅提升。在我的实际开发中,这种提升大约有 5~10 倍,倍数随着数据量递增。

并且,Object.freeze() 冻结的是值,你仍然可以将变量的引用替换掉。举个例子:

1
<p v-for="item in list">{{ item.value }}</p>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
new Vue({
data: {
// vue不会对list里的object做getter、setter绑定
list: Object.freeze([
{ value: 1 },
{ value: 2 }
])
},
created () {
// 界面不会有响应
this.list[0].value = 100;

// 下面两种做法,界面都会响应
this.list = [
{ value: 100 },
{ value: 200 }
];
this.list = Object.freeze([
{ value: 100 },
{ value: 200 }
]);
}
})

vue 的文档没有写上这个特性,但这是个非常实用的做法,对于纯展示的大数据,都可以使用 Object.freeze 提升性能。
https://juejin.cn/post/6844903922469961741

一个关于image访问图片跨域的问题

一、背景


项目中遇到一个问题,同一个图片在 dom 节点中使用了’img’ 标签来加载,同时由于项目使用了 ThreeJS 3D 渲染引擎,在加载纹理时使用了 TextureLoader 来加载了同一张图片,而由于图片是在阿里云服务器上的,所以最后报出了如下错误,意思是在访问图片时出现了跨域问题:



二、问题梳理

2.1 关于图片的加载


图片是来自于阿里云服务器的,和本地 localhost 必然存在跨域问题。通过 dom 节点的’img’ 标签来直接访问是没有问题,因为浏览器本身不会有跨域问题。问题出在通过 TextureLoader 来加载图片时出现了跨域问题。查看了 TextureLoader 的源码,发现其进一步使用了 ImageLoader 来加载图片,加载图片的代码大致如下:

1
2
3
4
5
6
7
8
9
10
crossOrigin: 'anonymous',
......
var image = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'img' );
......
if ( url.substr( 0, 5 ) !== 'data:' ) {
if ( this.crossOrigin !== undefined )
image.crossOrigin = this.crossOrigin;
}
......
image.src = url;


这段代码所描述的大致思路是:

  1. 通过 JS 代码,创建一个 img 的 dom element,然后使用这个 element 来加载图片。
  2. 默认情况下,设置了 crossOrigin 的跨域属性为’anonymous’。


所以,问题的关键在于,同一张图片,先用’img’ 标签去加载了,然后再在 JS 代码中,创建一个’img’ 并且设置了 crossOrigin 的跨域属性为’anonymous’,那么在 JS 中创建的’img’ 就会出现访问图片而产生跨域的问题。

2.2 关于 crossOrigin


关于 crossOrigin,我们看看 MDN 的解释。


这段话,用我自己的理解来解释一下:

  1. 加了 crossorigin 属性,则表明图片就一定会按照 CORS 来请求图片。而通过 CORS 请求到的图片可以再次被复用到 canvas 上进行绘制。换言之,如果不加 crossorigin 属性的话,那么图片是不能再次被复用到 canvas 上去的。
  2. 可以设置的值有 anonymous 以及 use-credentials,2 个 value 的作用都是设置通过 CORS 来请求图片,区别在于 use-credentials 是加了证书的 CORS。
  3. 如果默认用户不进行任何设置,那么就不会发起 CORS 请求。但如果设置了除 anonymous 和 use-credentials 以外的其他值,包括空字串在内,默认会当作 anonymous 来处理。

2.3 问题总结


通过前面 2 点的梳理,我们得出如下结论:

  1. 通过’img’ 加载的图片,浏览器默认情况下会将其缓存起来。
  2. 当我们从 JS 的代码中创建的’img’ 再去访问同一个图片时,浏览器就不会再发起新的请求,而是直接访问缓存的图片。但是由于 JS 中的’img’ 设置了 crossorigin,也就意味着它将要以 CORS 的方式请求,但缓存中的图片显然不是的,所以浏览器直接就拒绝了。连网络请求都没有发起。
  3. 在 Chrome 的调试器中,在 network 面板中,我们勾选了 disable cache 选项,验证了问题确实如第 2 点所述,浏览器这时发起了请求并且 JS 的’img’ 也能正常请求到图片。

三、解决问题


前面通过勾选 disable cache 来避免浏览器使用缓存图片而解决了问题,但实际用户不会这样使用啊。根据前面的梳理,’img’ 不跨域请求,而 JS 中的’img’ 跨域请求,所以不能访问缓存,那么是不是可以将 JS 中的’img’ 也设置成不跨域呢,于是将 JS 中的’img’ 的 crossorigin 设置为 undefine,结果图片是可以加载了,但又得到如下错误。





这段错误的意思是,这一个来自于 CORS 的图片,是不可以再次被复用到 canvas 上去的。这就验证了关于 crossorigin 中的第 1 点。


既然’img’ 和 JS 中的’img’ 都不加 crossorigin 不能解决 canvas 重用的问题,那么在两边同时都加上 crossorigin 呢?果然,在’img’ 中和 JS 中的’img’ 都加上 crossorigin = “anonymous”,图片可以正常加了,同时也可以被复用到’canvas’ 上去了。


另外,需要注意的 2 个小问题是:

  1. 服务器必须加上字段,否则,客户端设置了也是没用的。

Access-Control-Allow-Origin: *

  1. 如果是已经出了问题,你才看到这篇文章,或者才去想到这么解决。那么要记得先清理一下游览器所缓存的图片。否则你就会发现,有的图片可以访问,而有的不可以。那是因为缓存中之前存储了未 CORS 的图片。

四、总结


前面说了一框,只是想把这个过程完整的记录下来。整个问题的总结是:

  1. 同一张图片或者同一个地址,同时被’img’ 所访问,而随后后又会被如 JS 中去访问。而图片存储的地址是跨域的,那么就可能因为缓存问题而导致 JS 中的访问出现跨域问题。
  2. 解决的办法是让’img’ 标签和 JS 中的访问都走跨域访问的方式,这样既可以解决跨域访问的问题,也可以解决跨域图片在 canvas 中的复用。


最后,感谢你能读到并读完此文章,如果分析的过程中存在错误或者疑问都欢迎留言讨论。如果我的分享能够帮助到你,还请记得帮忙点个赞吧,谢谢。




安装掘金浏览器插件


打开新标签页发现好内容,掘金、GitHub、Dribbble、ProductHunt 等站点内容轻松获取。快来安装掘金浏览器插件获取高质量内容吧!
https://juejin.cn/post/6844903795726483463

掌握甩锅技术 Typescript 运行时数据校验



背景


大家出来写 代码的,难免会出 Bug。


文章背景就发生在一个 Bug 身上,


有一天,测试慌张中带着点兴奋冲过来:
测试:”xxx 系统前端线上出 Bug 了,点进 xx 页面一片空白啊”。
我:”纳尼?我写的 Bug 怎么会出现代码呢?”。




虽然大脑一片空白,但是锅还是要背的。
进入页面一看,哦豁,完蛋,cannot read the property 'xx' of undefined。确实是前端常见的报错呀。


背锅王,我当定了?未必。


我眉头一皱,发现事情并不是那么简单,经过一番猛如虎的操作之后,最终定位到问题是:后端接口响应的 JSON 数据中,一个嵌套比较深的字段没有返回,即前端只读到了 undefined


咱按章程办事,后端提供的接口文档指定了数据结构,那你没有返回正确数据结构,这就是你后端的锅,虽然严谨点前端也能捕获到错误进行处理,但归根到底,是你后端数据接口处理有问题,这锅,我不背。


甩锅又是一门扯皮的事情,杀敌一千自伤八百,锅已经扣下来了,想甩出去就难咯,。


唉,要是在接口出错的时候,能立刻知道接口数据出问题,先发制人,马上把锅甩出去那就好咯。


这就是本文即将要讲述的 “Typescript 运行时数据校验”。

为什么要运行时校验数据?


众所周知,TypescriptJavaScript 超集,可以给我们的项目代码提供静态类型检查,避免因为各种原因而未及时发现的代码错误,在编译时就能发现隐藏的代码隐患,从而提高代码质量。


但是,TypeScript 项目的一个常见问题是: 如何验证来自外部源的数据并将验证的数据与 TypeScript 类型联系起来。 即,如何避免后端 API 返回的数据与 Typescript 类型定义不一致导致的运行时错误。


Typescript 能用于运行时校验数据类型,那么有没有一种方法,能让我们在 运行时 也进行 Typescript 数据类型校验呢?

io-ts 解决方案?


业界开源了一个运行时校验的工具库:io-ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//  io-ts 例子
import * as t from 'io-ts'

// ts 定义
interface Category {
name: string
categories: Array<Category>
}

// 对应上述ts定义的 io-ts 实现
const Category: t.Type<Category> = t.recursion('Category', () =>
t.type({
name: t.string,
categories: t.array(Category)
})
)


但是,如上面的代码所示,这工具看起来就有点啰嗦有点难用,对代码的侵入性非常强,要全盘依据它的语法来重写代码。这对于一个团队来说,存在一定的迁移成本。


而我们更希望做到的理想方案是:


写好接口的数据结构 typescript 定义,不需要做太多的额外变动,直接就能校验后端接口响应的数据结构是否符合 typescript 接口定义

理想方案探索


首先,我们了解到,后端响应的数据接口一般为 JSON,那么,抛开 Typescript,如果要校验一个 JSON 的数据结构,我们可以怎么做到呢?


答案是JSON schema

JSON schema


JSON schema 是一种描述 JSON 数据格式的模式。


例如 typescript 数据结构:

1
2
3
4
5
6
type TypeSex = 1 | 2 | 3
interface UserInfo {
name: string
age?: number
sex: TypeSex
}


等价于以下的 json schema :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"$id": "api",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"UserInfo": {
"properties": {
"age": {
"type": "number"
},
"name": {
"type": "string"
},
"sex": {
"enum": [
1,
2,
3
],
"type": "number"
}
},
"required": [
"name",
"sex"
],
"type": "object"
}
}
}


根据已有 json-schema 校验库,即可校验数据对象

1
someValidateFunc(jsonSchema, apiResData)


这里大家可能就又会困惑:这json-schema写起来也太费劲了?还不一样要学习成本,那和 io-ts 有什么区别。


但是,既然我们同时知道 typescriptjson-schema 的语法定义规则,那么就两者必然能够互相转换。


也就是说,即便我们不懂 json-schema 的规范与语法,我们也能通过typescript 转化生成 json-schema


那么,在以上的前提下,我们的思路就是:既然 typescript 本身不支持运行时数据校验,那么我们可以将 typescript 先转化成 json schema, 然后用 json-schema 校验数据结构

typescript -> json-schema


要将 typescript 声明转换成 json-schema ,这里推荐使用 typescript-json-schema


我们可以直接使用它的命令行工具,这里就不仔细展开说明了,感兴趣的可以看下官方文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Usage: typescript-json-schema <path-to-typescript-files-or-tsconfig> <type>

Options:
--refs Create shared ref definitions. [boolean] [default: true]
--aliasRefs Create shared ref definitions for the type aliases. [boolean] [default: false]
--topRef Create a top-level ref definition. [boolean] [default: false]
--titles Creates titles in the output schema. [boolean] [default: false]
--defaultProps Create default properties definitions. [boolean] [default: false]
--noExtraProps Disable additional properties in objects by default. [boolean] [default: false]
--propOrder Create property order definitions. [boolean] [default: false]
--required Create required array for non-optional properties. [boolean] [default: false]
--strictNullChecks Make values non-nullable by default. [boolean] [default: false]
--useTypeOfKeyword Use `typeOf` keyword (https://goo.gl/DC6sni) for functions. [boolean] [default: false]
--out, -o The output file, defaults to using stdout
--validationKeywords Provide additional validation keywords to include [array] [default: []]
--include Further limit tsconfig to include only matching files [array] [default: []]
--ignoreErrors Generate even if the program has errors. [boolean] [default: false]
--excludePrivate Exclude private members from the schema [boolean] [default: false]
--uniqueNames Use unique names for type symbols. [boolean] [default: false]
--rejectDateType Rejects Date fields in type definitions. [boolean] [default: false]
--id Set schema id. [string] [default: ""]


github 上也有所有类型转换的 测试用例,可以对比看看 typescript 和 转换出的 json-schema 结果

json-schema 校验库


利用 typescript-json-schema 工具生成了 json-schema 文件后,我们需要根据该文件进行数据校验。


json-schema 数据校验的库很多,ajvjsonschema 之类的,这里用 jsonschema 作为示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Validator } from 'jsonschema'

import schema from './json-schema.json'

const v = new Validator()
// 绑定schema,这里的 `api` 对应 json-schema.json 的 `$id`
v.addSchema(schema, '/api')


const validateResponseData = (data: any) => {
// 校验响应数据
const result = v.validate(data, {
// SomeInterface 为 ts 定义的接口
$ref: `api#/definitions/SomeInterface`
})

// 校验失败,数据不符合预期
if (!result.valid) {
console.log('data is ', data)
console.log('errors', result.errors.map((item) => item.toString()))
}

return data
}


当我们校验以下数据时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 声明文件
interface UserInfo {
name: string
sex: string
age: number
phone?: number
}

// 校验结果
validateResponseData({
name: 'xxxx',
age: 'age应该是数字'
})
// 得出结果
// data is { name: 'xxxx', age: 'age应该是数字' }
// errors [ 'instance.age is not of a type(s) number',
// 'instance requires property "sex"' ]


完全例子请看 github


配合上前端上报系统,当线上系统接口返回了非预料的数据,导致出 bug,就可以实时知道到底错在哪了,并且及时甩锅给后端啦。

commit 时自动更新 json-schema


前面提到,我们需要执行 typescript-json-schema <path-to-typescript-files-or-tsconfig> <type> 命令来声明 typescript 对应的 json-schema 文件。


那么,这里就有个问题,接口数量有可能增加,接口数据也有可能变动,那也就代表着,我们每次变更接口数据结构,都要重新跑一下 typescript-json-schema ,时刻保持 json-schema 和 typescript 一一对应。


这我们就可以用 huskyprecommit , 加上 lint-staged 来实现每次更新提交代码时,自动执行 typescript-json-schema,无需时刻关注 typescript 接口定义的变更。


完全例子请看 github

总结


综上,我们实现了

  1. typescript 声明文件 转换生成 json-schema 文件
  2. 代码接口层拦截校验数据,如校验失败,通过前端上报系统 (如:sentry) 进行相关上报
  3. 通过 husky + lint-staged 每次提交代码自动执行 步骤 1,保持 git 仓库的代码 typescript 声明 和 json-schema 时刻保持一致。


那么,当 Bug 出现的时候,你甚至可以在测试都还没发现这个 Bug 之前,就已经把锅甩了出去。


只要你跑得足够快,Bug 就会追不上你。




https://github.com/SunshowerC/blog/issues/13

单点登录的三种实现方式

  • 前言
  • 实现方式一:父域 Cookie
  • 实现方式二:认证中心
  • 实现方式三:LocalStorage 跨域
  • 补充:域名分级

前言


在 B/S 系统中,登录功能通常都是基于 Cookie 来实现的。当用户登录成功后,一般会将登录状态记录到 Session 中,或者是给用户签发一个 Token,无论哪一种方式,都需要在客户端保存一些信息(Session ID 或 Token ),并要求客户端在之后的每次请求中携带它们。


在这样的场景下,使用 Cookie 无疑是最方便的,因此我们一般都会将 Session 的 ID 或 Token 保存到 Cookie 中,当服务端收到请求后,通过验证 Cookie 中的信息来判断用户是否登录 。


单点登录(Single Sign On, SSO)是指在同一帐号平台下的多个应用系统中,用户只需登录一次,即可访问所有相互信任的应用系统。


举例来说,百度贴吧和百度地图是百度公司旗下的两个不同的应用系统,如果用户在百度贴吧登录过之后,当他访问百度地图时无需再次登录,那么就说明百度贴吧和百度地图之间实现了单点登录。


单点登录的本质就是在多个应用系统中共享登录状态。如果用户的登录状态是记录在 Session 中的,要实现共享登录状态,就要先共享 Session,比如可以将 Session 序列化到 Redis 中,让多个应用系统共享同一个 Redis,直接读取 Redis 来获取 Session。


当然仅此是不够的,因为不同的应用系统有着不同的域名,尽管 Session 共享了,但是由于 Session ID 是往往保存在浏览器 Cookie 中的,因此存在作用域的限制,无法跨域名传递,也就是说当用户在 app1.com 中登录后,Session ID 仅在浏览器访问 app1.com 时才会自动在请求头中携带,而当浏览器访问 app2.com 时,Session ID 是不会被带过去的。实现单点登录的关键在于,如何让 Session ID(或 Token)在多个域中共享。


在将具体实现之前,我们先来聊一聊 Cookie 的作用域。


Cookie 的作用域由 domain 属性和 path 属性共同决定。domain 属性的有效值为当前域或其父域的域名 / IP 地址,在 Tomcat 中,domain 属性默认为当前域的域名 / IP 地址。path 属性的有效值是以 “/” 开头的路径,在 Tomcat 中,path 属性默认为当前 Web 应用的上下文路径。


如果将 Cookie 的 domain 属性设置为当前域的父域,那么就认为它是父域 Cookie。Cookie 有一个特点,即父域中的 Cookie 被子域所共享,换言之,子域会自动继承父域中的 Cookie。


利用 Cookie 的这个特点,不难想到,将 Session ID(或 Token)保存到父域中不就行了。没错,我们只需要将 Cookie 的 domain 属性设置为父域的域名(主域名),同时将 Cookie 的 path 属性设置为根路径,这样所有的子域应用就都可以访问到这个 Cookie 了。


不过这要求应用系统的域名需建立在一个共同的主域名之下,如 tieba.baidu.com 和 map.baidu.com,它们都建立在 baidu.com 这个主域名之下,那么它们就可以通过这种方式来实现单点登录。


总结:此种实现方式比较简单,但不支持跨主域名。

实现方式二:认证中心


我们可以部署一个认证中心,认证中心就是一个专门负责处理登录请求的独立的 Web 服务。


用户统一在认证中心进行登录,登录成功后,认证中心记录用户的登录状态,并将 Token 写入 Cookie。(注意这个 Cookie 是认证中心的,应用系统是访问不到的。)


应用系统检查当前请求有没有 Token,如果没有,说明用户在当前系统中尚未登录,那么就将页面跳转至认证中心。由于这个操作会将认证中心的 Cookie 自动带过去,因此,认证中心能够根据 Cookie 知道用户是否已经登录过了。


如果认证中心发现用户尚未登录,则返回登录页面,等待用户登录,如果发现用户已经登录过了,就不会让用户再次登录了,而是会跳转回目标 URL ,并在跳转前生成一个 Token,拼接在目标 URL 的后面,回传给目标应用系统。


应用系统拿到 Token 之后,还需要向认证中心确认下 Token 的合法性,防止用户伪造。确认无误后,应用系统记录用户的登录状态,并将 Token 写入 Cookie,然后给本次访问放行。(注意这个 Cookie 是当前应用系统的,其他应用系统是访问不到的。)当用户再次访问当前应用系统时,就会自动带上这个 Token,应用系统验证 Token 发现用户已登录,于是就不会有认证中心什么事了。


这里顺便介绍两款认证中心的开源实现:

  • Apereo CAS 是一个企业级单点登录系统,其中 CAS 的意思是”Central Authentication Service“。它最初是耶鲁大学实验室的项目,后来转让给了 JASIG 组织,项目更名为 JASIG CAS,后来该组织并入了 Apereo 基金会,项目也随之更名为 Apereo CAS。
  • XXL-SSO 是一个简易的单点登录系统,由大众点评工程师许雪里个人开发,代码比较简单,没有做安全控制,因而不推荐直接应用在项目中,这里列出来仅供参考。


总结:此种实现方式相对复杂,支持跨域,扩展性好,是单点登录的标准做法。

实现方式三:LocalStorage 跨域


前面,我们说实现单点登录的关键在于,如何让 Session ID(或 Token)在多个域中共享。


父域 Cookie 确实是一种不错的解决方案,但是不支持跨域。那么有没有什么奇淫技巧能够让 Cookie 跨域传递呢?


很遗憾,浏览器对 Cookie 的跨域限制越来越严格。Chrome 浏览器还给 Cookie 新增了一个 SameSite 属性,此举几乎禁止了一切跨域请求的 Cookie 传递(超链接除外),并且只有当使用 HTTPs 协议时,才有可能被允许在 AJAX 跨域请求中接受服务器传来的 Cookie。


不过,在前后端分离的情况下,完全可以不使用 Cookie,我们可以选择将 Session ID (或 Token )保存到浏览器的 LocalStorage 中,让前端在每次向后端发送请求时,主动将 LocalStorage 的数据传递给服务端。这些都是由前端来控制的,后端需要做的仅仅是在用户登录成功后,将 Session ID (或 Token )放在响应体中传递给前端。


在这样的场景下,单点登录完全可以在前端实现。前端拿到 Session ID (或 Token )后,除了将它写入自己的 LocalStorage 中之外,还可以通过特殊手段将它写入多个其他域下的 LocalStorage 中。


关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获取 token
var token = result.data.token;

// 动态创建一个不可见的 iframe,在 iframe 中加载一个跨域 HTML
var iframe = document.createElement("iframe");
iframe.src = "http://app1.com/localstorage.html";
document.body.append(iframe);

// 使用 postMessage() 方法将 token 传递给 iframe
setTimeout(function () {
iframe.contentWindow.postMessage(token, "http://app1.com");
}, 4000);
setTimeout(function () {
iframe.remove();
}, 6000);

// 在这个 iframe 所加载的 HTML 中绑定一个事件监听器,当事件被触发时,把接收到的 token 数据写入 localStorage
window.addEventListener('message', function (event) {
localStorage.setItem('token', event.data)
}, false);


前端通过 iframe+postMessage() 方式,将同一份 Token 写入到了多个域下的 LocalStorage 中,前端每次在向后端发送请求之前,都会主动从 LocalStorage 中读取 Token 并在请求中携带,这样就实现了同一份 Token 被多个域所共享。

总结:此种实现方式完全由前端控制,几乎不需要后端参与,同样支持跨域。

补充:域名分级


从专业的角度来说(根据《计算机网络》中的定义),.com、.cn 为一级域名(也称顶级域名),.com.cn、baidu.com 为二级域名,sina.com.cn、tieba.baidu.com 为三级域名,以此类推,N 级域名就是 N-1 级域名的直接子域名。


从使用者的角度来说,一般把可支持独立备案的主域名称作一级域名,如 baidu.com、sina.com.cn 皆可称作一级域名,在主域名下建立的直接子域名称作二级域名,如 tieba.baidu.com 为二级域名。


为了避免歧义,本人将使用 “主域名“替代” 一级域名“的说法。


推荐阅读  点击标题可跳转


那些总是写 “烂代码” 的同学,强烈推荐你用这款 IDEA 插件!


国内又一起 “删库跑路” 事件:程序员怒删公司 9TB 数据,判刑 7 年!


Eclipse 出品,1.3 万 Star!网友说要干掉 VS Code 的新工具


看完本文有收获?请转发分享给更多人


关注「ImportNew」,提升 Java 技能





好文章,我在看❤️
https://mp.weixin.qq.com/s?__biz=MjM5NzMyMjAwMA==&mid=2651492507&idx=1&sn=734a4c50620ac22bb4a611129e8d25e7&chksm=bd25fce48a5275f2fb63e4d55b561893ebae700426e70fbdae8a80ff569ea375cbdfa01619b6&scene=132#wechat_redirect

rollup初体验

进入一个目录,npm init 进行初始化,可以一路回车


安装ts
npm install -D typescript      


生存配置文件
./node_modules/.bin/tsc –init




根目录下新建一个rollup配置文件
rollup.config.js

添加以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import clear from 'rollup-plugin-clear'; // 转换cjs
import commonjs from 'rollup-plugin-commonjs'; // 转换cjs
import { terser } from 'rollup-plugin-terser'; // 压缩,可以判断模式,开发模式不加入到plugins
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
import typescript from 'rollup-plugin-typescript';

export default {
input: 'src/index.ts', // 源文件入口
output: [
{
file: 'dist/browser-version.esm.js', // package.json 中 "module": "dist/browser-version.esm.js"
format: 'esm', // es module 形式的包, 用来import 导入, 可以tree shaking
sourcemap: false
}, {
file: 'dist/browser-version.cjs.js', // package.json 中 "main": "dist/browser-version.cjs.js",
format: 'cjs', // commonjs 形式的包, require 导入
sourcemap: false
}, {
file: 'dist/browser-version.umd.js',
name: 'GLWidget',
format: 'umd', // umd 兼容形式的包, 可以直接应用于网页 script
sourcemap: false,
}
],
plugins: [
clear({
targets: ['dist']
}),
resolve(),
babel({
exclude: 'node_modules/**'
}),
typescript(),
commonjs(),
terser(),
]
}


修改packagejson

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
"name": "browser-version-tool-html",
"version": "1.0.0",
"description": "Print tips on outdate browser. ",
"main": "dist/browser-version.cjs.js",
"module": "dist/browser-version.esm.js",
"browser": "dist/browser-version.umd.js",
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"test": "ts-node test/test.ts",
"pretest": "npm run build"
},
"author": "Yenkos",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.12.10",
"@rollup/plugin-html": "^0.2.0",
"@types/ms": "^0.7.31",
"babel-plugin-external-helpers": "^6.22.0",
"babel-preset-latest": "^6.24.1",
"rollup": "^2.35.1",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-clear": "^2.0.7",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-hash": "^1.3.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript": "^1.0.1",
"rollup-plugin-uglify": "^6.0.4",
"ts-node": "^9.1.1",
"tslib": "^2.0.3",
"typescript": "^4.1.3"
},
"types": "dist/index.d.ts"
}


npm i 安装依赖


进入src/index.ts 编写代码


rollup 打包
npm run build


发布包到npm

首先需要注册npm账号

npm adduser
npm login
npm publish



package-lock.json的作用

什么是package-lock.json

在前端项目根目录里有一个package-lock.json文件,有同学疑惑这个文件是干嘛用,不是已经有一个package文件了吗?这个文件前身是npm-shrinkwrap.json,因为npm的依赖管理非常宽松,一个项目在同一天内都可能会安装到不同版本的依赖包,依赖包的升级可能会给项目带来bug。

这时shrinkwrap就应运而生,但是大家都懒得写,所以npm在5+版本之后,收到yarn.lock的启发,默认会在安装时生成package-loack文件。


举个例子

总结

package-lock是用来严格控制版本依赖的。

package-lock.json 详细解析

package-lock.json会为npm修改node_modules树或的任何操作自动生成package.json。它描述了生成的确切树,因此无论中间依赖项更新如何,后续安装都可以生成相同的树。该文件旨在提交到源存储库中,并具有多种用途:

  • 描述依赖关系树的单个表示,这样可以确保队友,部署和持续集成安装完全相同的依赖关系。
  • 为用户提供一种工具,使其可以“时间旅行”到以前的状态,node_modules而不必提交目录本身。
  • 为了通过可读的源代码控制差异更好地了解树的变化。
  • 并允许npm跳过以前安装的软件包的重复元数据解析,从而优化安装过程。

关于package-lock.json它的一个关键细节是它无法发布,并且如果在顶级软件包之外的任何地方找到它,它将被忽略。它与共享格式npm-shrinkwrap.json,该文件本质上是相同的文件,但可以发布。除非部署CLI工具或使用发布过程来生产生产软件包,否则不建议这样做。如果软件包的根目录中同时存在package-lock.jsonnpm-shrinkwrap.jsonpackage-lock.json将被完全忽略。

文件格式

name

这是程序包锁定的程序包名称。这必须与中的内容匹配 package.json

version

这是程序包锁定的程序包版本。这必须与中的内容匹配 package.json

lockfileVersion

整数版本,1从此文档的版本号开始,在生成this时使用了其语义package-lock.json

packageIntegrity

这是从中创建的子资源完整性package.jsonpackage.json不应进行任何预处理。子资源完整性字符串可由类似的模块生成 ssri

表示安装是在NODE_PRESERVE_SYMLINKS启用环境变量的情况下完成的 。安装程序应坚持使该属性的值与该环境变量匹配。

dependencies

程序包名称到依赖对象的映射。依赖项对象具有以下属性:version这是一个说明符,可唯一标识此程序包,并应可用于获取其新副本。

  • 捆绑的依赖关系:不管来源如何,这都是一个纯粹用于参考目的的版本号。
  • 注册表源:这是一个版本号。(例如,1.2.3
  • git源:这是一个具有已解决承诺的git说明符。(例如,git+https://example.com/foo/bar#115311855adb0789a0466714ed48a1499ffea97e
  • http tarball来源:这是tarball的URL。(例如,[https://example.com/example-1.3.0.tgz](https://example.com/example-1.3.0.tgz)
  • 本地tarball来源:这是tarball的文件URL。(例如file:///opt/storage/example-1.3.0.tgz
  • 本地链接源:这是链接的文件URL。(例如file:libs/our-module
integrity

这是此资源的标准子资源完整性

  • 对于捆绑的依赖项,无论来源如何,均不包括在内。
  • 对于注册表源,这是integrity注册表提供的,或者如果未提供SHA1 shasum
  • 对于git源,这是我们从中克隆的特定提交哈希。
  • 对于远程tarball源,这是基于文件SHA512的完整性。
  • 对于本地tarball源:这是基于文件SHA512的完整性字段。
resolved
  • 对于捆绑的依赖项,无论来源如何,均不包括在内。
  • 对于注册表源,这是相对于注册表URL的压缩包的路径。如果tarball URL与注册表URL不在同一服务器上,则这是完整的URL。
bundled

如果为true,则为捆绑的依赖关系,并将由父模块安装。安装时,此模块将在提取阶段从父模块中提取,而不是作为单独的依赖项安装。

dev

如果为true,则此依赖项仅是顶层模块的开发依赖项,或者是一个传递性依赖项。对于既是顶层的开发依赖关系又是顶层的非开发依赖关系的传递依赖关系的依赖关系,这是错误的。

optional

如果为true,则此依赖项仅是顶层模块的可选依赖项,或者是一个传递性依赖项。对于既是顶层的可选依赖关系又是顶层的非可选依赖关系的传递性依赖关系的依赖关系,则为false。
即使所有可选依赖项都可以在当前平台上卸载,也应包括在内。

requires

这是模块名称到版本的映射。这是此模块所需的所有内容的列表,无论它将安装在何处。版本应通过正常匹配规则匹配我们dependencies或更高级别的依赖关系 。

dependencies

此依赖关系的依赖关系,与顶层完全相同。