dongzhe3917875

dongzhe3917875 查看完整档案

北京编辑中国传媒大学  |  通信与信息系统 编辑字节跳动  |  web前端开发工程师 编辑 dongzhetech.cn 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

dongzhe3917875 收藏了文章 · 10月16日

翻译连载 | 附录 A:Transducing(上)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

关于译者:这是一个流淌着沪江血液的纯粹工程:认真,是 HTML 最坚实的梁柱;分享,是 CSS 里最闪耀的一瞥;总结,是 JavaScript 中最严谨的逻辑。经过捶打磨练,成就了本书的中文版。本书包含了函数式编程之精髓,希望可以帮助大家在学习函数式编程的道路上走的更顺畅。比心。

译者团队(排名不分先后):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry萝卜vavd317vivaxy萌萌zhouyao

JavaScript 轻量级函数式编程

附录 A:Transducing

Transducing 是我们这本书要讲到的更为高级的技术。它继承了第 8 章数组操作的许多思想。

我不会把 Transducing 严格的称为“轻量级函数式编程”,它更像是一个顶级的技巧。我把这个技术留到附录来讲意味着你现在很可能并不需要关心它,当你确保你已经非常熟悉整本书的主要内容,你可以再回头看看这一章节。

说实话,即使我已经教过 transducing 很多次了,在写这一章的时候,我仍然需要花很多脑力去理清楚这个技术。所以,如果你看这一章看的很疑惑也没必要感到沮丧。把这一章加个书签,等你觉得你差不多能理解时再回头看看。

Transducing 就是通过减少来转换。

我知道这听起来很令人费解。但是让我们来看看它有多强大。实际上,我认为这是你掌握了轻量级函数式编程后可以做的最好的例证之一。

和这本书的其他部分一样,我的方法是先解释为什么使用这个技术,然后如何使用,最后归结为简单的这个技术到底是什么样的。这通常会有多学很多东西,但是我觉得用这种方式你会更深入的理解它。

首先,为什么

让我们从扩展我们在第 3 章中介绍的例子开始,测试单词是否足够短和/或足够长:

function isLongEnough(str) {
    return str.length >= 5;
}

function isShortEnough(str) {
    return str.length <= 10;
}

在第 3 章中,我们使用这些断言函数来测试一个单词。然后在第 8 章中,我们学习了如何使用像 filter(..) 这样的数组操作来重复这些测试。例如:

var words = [ "You", "have", "written", "something", "very", "interesting" ];

words
.filter( isLongEnough )
.filter( isShortEnough );
// ["written","something"]

这个例子可能并不明显,但是这种分开操作相同数组的方式具有一些不理想的地方。当我们处理一个值比较少的数组时一切都还好。但是如果数组中有很多值,每个 filter(..) 分别处理数组的每个值会比我们预期的慢一点。

当我们的数组是异步/懒惰(也称为 observables)的,随着时间的推移响应事件处理(见第 10 章),会出现类似的性能问题。在这种情况下,一次事件只有一个值,因此使用两个单独的 filter(..) 函数处理这些值并不是什么大不了的事情。

但是,不太明显的是每个 filter(..) 方法都会产生一个单独的 observable 值。从一个 observable 值中抽出一个值的开销真的可以加起来(译者注:详情请看第 10 章的“积极的 vs 惰性的”这一节)。这是真实存在的,因为在这些情况下,处理数千或数百万的值并不罕见; 所以,即使是这么小的成本也会很快累加起来。

另一个缺点是可读性,特别是当我们需要对多个数组(或 observable)重复相同的操作时。例如:

zip(
    list1.filter( isLongEnough ).filter( isShortEnough ),
    list2.filter( isLongEnough ).filter( isShortEnough ),
    list3.filter( isLongEnough ).filter( isShortEnough )
)

显得很重复,对不对?

如果我们可以将 isLongEnough(..) 断言与 isShortEnough(..) 断言组合在一起是不是会更好一点呢(可读性和性能)?你可以手动执行:

function isCorrectLength(str) {
    return isLongEnough( str ) && isShortEnough( str );
}

但这不是函数式编程的方式!

在第 8 章中,我们讨论了融合 —— 组合相邻映射函数。回忆一下:

words
.map(
    pipe( removeInvalidChars, upper, elide )
);

不幸的是,组合相邻断言函数并不像组合相邻映射函数那样容易。为什么呢?想想断言函数长什么“样子” —— 一种描述输入和输出的学术方式。它接收一个单一的参数,返回一个 true 或 false。

如果你试着用 isshortenough(islongenough(str)),这是行不通的。因为 islongenough(..) 会返回 true 或者 false ,而不是返回 isshortenough(..) 所要的字符串类型的值。这可真倒霉。

试图组合两个相邻的 reducer 函数同样是行不通的。reducer 函数接收两个值作为输入,并返回单个组合值。reducer 函数的单一返回值也不能作为参数传到另一个需要两个输入的 reducer 函数中。

此外,reduce(..) 辅助函数可以接收一个可选的 initialValue 输入。有时可以省略,但有时候它又必须被传入。这就让组合更复杂了,因为一个 reduce(..) 可能需要一个 initialValue,而另一个 reduce(..) 可能需要另一个 initialValue。所以我们怎么可能只用某种组合的 reducer 来实现 reduce(..) 呢。

考虑像这样的链:

words
.map( strUppercase )
.filter( isLongEnough )
.filter( isShortEnough )
.reduce( strConcat, "" );
// "WRITTENSOMETHING"

你能想出一个组合能够包含 map(strUppercase)filter(isLongEnough)filter(isShortEnough)reduce(strConcat) 所有这些操作吗?每种操作的行为是不同的,所以不能直接组合在一起。我们需要把它们修改下让它们组合在一起。

希望这些例子说明了为什么简单的组合不能胜任这项任务。我们需要一个更强大的技术,而 transducing 就是这个技术。

如何,下一步

让我们谈谈我们该如何得到一个能组合映射,断言和/或 reducers 的框架。

别太紧张:你不必经历编程过程中所有的探索步骤。一旦你理解了 transducing 能解决的问题,你就可以直接使用函数式编程库中的 transduce(..) 工具继续你应用程序的剩余部分!

让我们开始探索吧。

把 Map/Filter 表示为 Reduce

我们要做的第一件事情就是将我们的 filter(..)map(..)调用变为 reduce(..) 调用。回想一下我们在第 8 章是怎么做的:

function strUppercase(str) { return str.toUpperCase(); }
function strConcat(str1,str2) { return str1 + str2; }

function strUppercaseReducer(list,str) {
    list.push( strUppercase( str ) );
    return list;
}

function isLongEnoughReducer(list,str) {
    if (isLongEnough( str )) list.push( str );
    return list;
}

function isShortEnoughReducer(list,str) {
    if (isShortEnough( str )) list.push( str );
    return list;
}

words
.reduce( strUppercaseReducer, [] )
.reduce( isLongEnoughReducer, [] )
.reduce( isShortEnough, [] )
.reduce( strConcat, "" );
// "WRITTENSOMETHING"

这是一个不错的改进。我们现在有四个相邻的 reduce(..) 调用,而不是三种不同方法的混合。然而,我们仍然不能 compose(..) 这四个 reducer,因为它们接受两个参数而不是一个参数。

在 8 章,我们偷了点懒使用了数组的 push 方法而不是 concat(..) 方法返回一个新数组,导致有副作用。现在让我们更正式一点:

function strUppercaseReducer(list,str) {
    return list.concat( [strUppercase( str )] );
}

function isLongEnoughReducer(list,str) {
    if (isLongEnough( str )) return list.concat( [str] );
    return list;
}

function isShortEnoughReducer(list,str) {
    if (isShortEnough( str )) return list.concat( [str] );
    return list;
}

在后面我们会来头看看这里是否需要 concat(..)

参数化 Reducers

除了使用不同的断言函数之外,两个 filter reducers 几乎相同。让我们把这些 reducers 参数化得到一个可以定义任何 filter-reducer 的工具函数:

function filterReducer(predicateFn) {
    return function reducer(list,val){
        if (predicateFn( val )) return list.concat( [val] );
        return list;
    };
}

var isLongEnoughReducer = filterReducer( isLongEnough );
var isShortEnoughReducer = filterReducer( isShortEnough );

同样的,我们把 mapperFn(..) 也参数化来生成 map-reducer 函数:

function mapReducer(mapperFn) {
    return function reducer(list,val){
        return list.concat( [mapperFn( val )] );
    };
}

var strToUppercaseReducer = mapReducer( strUppercase );

我们的调用链看起来是一样的:

words
.reduce( strUppercaseReducer, [] )
.reduce( isLongEnoughReducer, [] )
.reduce( isShortEnough, [] )
.reduce( strConcat, "" );

提取共用组合逻辑

仔细观察上面的 mapReducer(..)filterReducer(..) 函数。你发现共享功能了吗?

这部分:

return list.concat( .. );

// 或者
return list;

让我们为这个通用逻辑定义一个辅助函数。但是我们叫它什么呢?

function WHATSITCALLED(list,val) {
    return list.concat( [val] );
}

WHATSITCALLED(..) 函数做了些什么呢,它接收两个参数(一个数组和另一个值),将值 concat 到数组的末尾返回一个新的数组。所以这个 WHATSITCALLED(..) 名字不合适,我们可以叫它 listCombination(..)

function listCombination(list,val) {
    return list.concat( [val] );
}

我们现在用 listCombination(..) 来重新定义我们的 reducer 辅助函数:

function mapReducer(mapperFn) {
    return function reducer(list,val){
        return listCombination( list, mapperFn( val ) );
    };
}

function filterReducer(predicateFn) {
    return function reducer(list,val){
        if (predicateFn( val )) return listCombination( list, val );
        return list;
    };
}

我们的调用链看起来还是一样的(这里就不重复写了)。

参数化组合

我们的 listCombination(..) 小工具只是组合两个值的一种方式。让我们将它的用途参数化,以使我们的 reducers 更加通用:

function mapReducer(mapperFn,combinationFn) {
    return function reducer(list,val){
        return combinationFn( list, mapperFn( val ) );
    };
}

function filterReducer(predicateFn,combinationFn) {
    return function reducer(list,val){
        if (predicateFn( val )) return combinationFn( list, val );
        return list;
    };
}

使用这种形式的辅助函数:

var strToUppercaseReducer = mapReducer( strUppercase, listCombination );
var isLongEnoughReducer = filterReducer( isLongEnough, listCombination );
var isShortEnoughReducer = filterReducer( isShortEnough, listCombination );

将这些实用函数定义为接收两个参数而不是一个参数不太方便组合,因此我们使用我们的 curry(..) (柯里化)方法:

var curriedMapReducer = curry( function mapReducer(mapperFn,combinationFn){
    return function reducer(list,val){
        return combinationFn( list, mapperFn( val ) );
    };
} );

var curriedFilterReducer = curry( function filterReducer(predicateFn,combinationFn){
    return function reducer(list,val){
        if (predicateFn( val )) return combinationFn( list, val );
        return list;
    };
} );

var strToUppercaseReducer =
    curriedMapReducer( strUppercase )( listCombination );
var isLongEnoughReducer =
    curriedFilterReducer( isLongEnough )( listCombination );
var isShortEnoughReducer =
    curriedFilterReducer( isShortEnough )( listCombination );

这看起来有点冗长而且可能不是很有用。

但这实际上是我们进行下一步推导的必要条件。请记住,我们的最终目标是能够 compose(..) 这些 reducers。我们快要完成了。

 附录 A:Transducing(下)---- 四天后更新

** 【上一章】[翻译连载 | 第 11 章:融会贯通 -《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇
](https://juejin.im/post/5a0cf1... **

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。

iKcamp官网:https://www.ikcamp.com
访问官网更快阅读全部免费分享课程:
《iKcamp出品|全网最新|微信小程序|基于最新版1.0开发者工具之初中级培训教程分享》
《iKcamp出品|基于Koa2搭建Node.js实战项目教程》
包含:文章、视频、源代码

查看原文

dongzhe3917875 收藏了文章 · 10月16日

翻译连载 | 附录 A:Transducing(上)-《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇

关于译者:这是一个流淌着沪江血液的纯粹工程:认真,是 HTML 最坚实的梁柱;分享,是 CSS 里最闪耀的一瞥;总结,是 JavaScript 中最严谨的逻辑。经过捶打磨练,成就了本书的中文版。本书包含了函数式编程之精髓,希望可以帮助大家在学习函数式编程的道路上走的更顺畅。比心。

译者团队(排名不分先后):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry萝卜vavd317vivaxy萌萌zhouyao

JavaScript 轻量级函数式编程

附录 A:Transducing

Transducing 是我们这本书要讲到的更为高级的技术。它继承了第 8 章数组操作的许多思想。

我不会把 Transducing 严格的称为“轻量级函数式编程”,它更像是一个顶级的技巧。我把这个技术留到附录来讲意味着你现在很可能并不需要关心它,当你确保你已经非常熟悉整本书的主要内容,你可以再回头看看这一章节。

说实话,即使我已经教过 transducing 很多次了,在写这一章的时候,我仍然需要花很多脑力去理清楚这个技术。所以,如果你看这一章看的很疑惑也没必要感到沮丧。把这一章加个书签,等你觉得你差不多能理解时再回头看看。

Transducing 就是通过减少来转换。

我知道这听起来很令人费解。但是让我们来看看它有多强大。实际上,我认为这是你掌握了轻量级函数式编程后可以做的最好的例证之一。

和这本书的其他部分一样,我的方法是先解释为什么使用这个技术,然后如何使用,最后归结为简单的这个技术到底是什么样的。这通常会有多学很多东西,但是我觉得用这种方式你会更深入的理解它。

首先,为什么

让我们从扩展我们在第 3 章中介绍的例子开始,测试单词是否足够短和/或足够长:

function isLongEnough(str) {
    return str.length >= 5;
}

function isShortEnough(str) {
    return str.length <= 10;
}

在第 3 章中,我们使用这些断言函数来测试一个单词。然后在第 8 章中,我们学习了如何使用像 filter(..) 这样的数组操作来重复这些测试。例如:

var words = [ "You", "have", "written", "something", "very", "interesting" ];

words
.filter( isLongEnough )
.filter( isShortEnough );
// ["written","something"]

这个例子可能并不明显,但是这种分开操作相同数组的方式具有一些不理想的地方。当我们处理一个值比较少的数组时一切都还好。但是如果数组中有很多值,每个 filter(..) 分别处理数组的每个值会比我们预期的慢一点。

当我们的数组是异步/懒惰(也称为 observables)的,随着时间的推移响应事件处理(见第 10 章),会出现类似的性能问题。在这种情况下,一次事件只有一个值,因此使用两个单独的 filter(..) 函数处理这些值并不是什么大不了的事情。

但是,不太明显的是每个 filter(..) 方法都会产生一个单独的 observable 值。从一个 observable 值中抽出一个值的开销真的可以加起来(译者注:详情请看第 10 章的“积极的 vs 惰性的”这一节)。这是真实存在的,因为在这些情况下,处理数千或数百万的值并不罕见; 所以,即使是这么小的成本也会很快累加起来。

另一个缺点是可读性,特别是当我们需要对多个数组(或 observable)重复相同的操作时。例如:

zip(
    list1.filter( isLongEnough ).filter( isShortEnough ),
    list2.filter( isLongEnough ).filter( isShortEnough ),
    list3.filter( isLongEnough ).filter( isShortEnough )
)

显得很重复,对不对?

如果我们可以将 isLongEnough(..) 断言与 isShortEnough(..) 断言组合在一起是不是会更好一点呢(可读性和性能)?你可以手动执行:

function isCorrectLength(str) {
    return isLongEnough( str ) && isShortEnough( str );
}

但这不是函数式编程的方式!

在第 8 章中,我们讨论了融合 —— 组合相邻映射函数。回忆一下:

words
.map(
    pipe( removeInvalidChars, upper, elide )
);

不幸的是,组合相邻断言函数并不像组合相邻映射函数那样容易。为什么呢?想想断言函数长什么“样子” —— 一种描述输入和输出的学术方式。它接收一个单一的参数,返回一个 true 或 false。

如果你试着用 isshortenough(islongenough(str)),这是行不通的。因为 islongenough(..) 会返回 true 或者 false ,而不是返回 isshortenough(..) 所要的字符串类型的值。这可真倒霉。

试图组合两个相邻的 reducer 函数同样是行不通的。reducer 函数接收两个值作为输入,并返回单个组合值。reducer 函数的单一返回值也不能作为参数传到另一个需要两个输入的 reducer 函数中。

此外,reduce(..) 辅助函数可以接收一个可选的 initialValue 输入。有时可以省略,但有时候它又必须被传入。这就让组合更复杂了,因为一个 reduce(..) 可能需要一个 initialValue,而另一个 reduce(..) 可能需要另一个 initialValue。所以我们怎么可能只用某种组合的 reducer 来实现 reduce(..) 呢。

考虑像这样的链:

words
.map( strUppercase )
.filter( isLongEnough )
.filter( isShortEnough )
.reduce( strConcat, "" );
// "WRITTENSOMETHING"

你能想出一个组合能够包含 map(strUppercase)filter(isLongEnough)filter(isShortEnough)reduce(strConcat) 所有这些操作吗?每种操作的行为是不同的,所以不能直接组合在一起。我们需要把它们修改下让它们组合在一起。

希望这些例子说明了为什么简单的组合不能胜任这项任务。我们需要一个更强大的技术,而 transducing 就是这个技术。

如何,下一步

让我们谈谈我们该如何得到一个能组合映射,断言和/或 reducers 的框架。

别太紧张:你不必经历编程过程中所有的探索步骤。一旦你理解了 transducing 能解决的问题,你就可以直接使用函数式编程库中的 transduce(..) 工具继续你应用程序的剩余部分!

让我们开始探索吧。

把 Map/Filter 表示为 Reduce

我们要做的第一件事情就是将我们的 filter(..)map(..)调用变为 reduce(..) 调用。回想一下我们在第 8 章是怎么做的:

function strUppercase(str) { return str.toUpperCase(); }
function strConcat(str1,str2) { return str1 + str2; }

function strUppercaseReducer(list,str) {
    list.push( strUppercase( str ) );
    return list;
}

function isLongEnoughReducer(list,str) {
    if (isLongEnough( str )) list.push( str );
    return list;
}

function isShortEnoughReducer(list,str) {
    if (isShortEnough( str )) list.push( str );
    return list;
}

words
.reduce( strUppercaseReducer, [] )
.reduce( isLongEnoughReducer, [] )
.reduce( isShortEnough, [] )
.reduce( strConcat, "" );
// "WRITTENSOMETHING"

这是一个不错的改进。我们现在有四个相邻的 reduce(..) 调用,而不是三种不同方法的混合。然而,我们仍然不能 compose(..) 这四个 reducer,因为它们接受两个参数而不是一个参数。

在 8 章,我们偷了点懒使用了数组的 push 方法而不是 concat(..) 方法返回一个新数组,导致有副作用。现在让我们更正式一点:

function strUppercaseReducer(list,str) {
    return list.concat( [strUppercase( str )] );
}

function isLongEnoughReducer(list,str) {
    if (isLongEnough( str )) return list.concat( [str] );
    return list;
}

function isShortEnoughReducer(list,str) {
    if (isShortEnough( str )) return list.concat( [str] );
    return list;
}

在后面我们会来头看看这里是否需要 concat(..)

参数化 Reducers

除了使用不同的断言函数之外,两个 filter reducers 几乎相同。让我们把这些 reducers 参数化得到一个可以定义任何 filter-reducer 的工具函数:

function filterReducer(predicateFn) {
    return function reducer(list,val){
        if (predicateFn( val )) return list.concat( [val] );
        return list;
    };
}

var isLongEnoughReducer = filterReducer( isLongEnough );
var isShortEnoughReducer = filterReducer( isShortEnough );

同样的,我们把 mapperFn(..) 也参数化来生成 map-reducer 函数:

function mapReducer(mapperFn) {
    return function reducer(list,val){
        return list.concat( [mapperFn( val )] );
    };
}

var strToUppercaseReducer = mapReducer( strUppercase );

我们的调用链看起来是一样的:

words
.reduce( strUppercaseReducer, [] )
.reduce( isLongEnoughReducer, [] )
.reduce( isShortEnough, [] )
.reduce( strConcat, "" );

提取共用组合逻辑

仔细观察上面的 mapReducer(..)filterReducer(..) 函数。你发现共享功能了吗?

这部分:

return list.concat( .. );

// 或者
return list;

让我们为这个通用逻辑定义一个辅助函数。但是我们叫它什么呢?

function WHATSITCALLED(list,val) {
    return list.concat( [val] );
}

WHATSITCALLED(..) 函数做了些什么呢,它接收两个参数(一个数组和另一个值),将值 concat 到数组的末尾返回一个新的数组。所以这个 WHATSITCALLED(..) 名字不合适,我们可以叫它 listCombination(..)

function listCombination(list,val) {
    return list.concat( [val] );
}

我们现在用 listCombination(..) 来重新定义我们的 reducer 辅助函数:

function mapReducer(mapperFn) {
    return function reducer(list,val){
        return listCombination( list, mapperFn( val ) );
    };
}

function filterReducer(predicateFn) {
    return function reducer(list,val){
        if (predicateFn( val )) return listCombination( list, val );
        return list;
    };
}

我们的调用链看起来还是一样的(这里就不重复写了)。

参数化组合

我们的 listCombination(..) 小工具只是组合两个值的一种方式。让我们将它的用途参数化,以使我们的 reducers 更加通用:

function mapReducer(mapperFn,combinationFn) {
    return function reducer(list,val){
        return combinationFn( list, mapperFn( val ) );
    };
}

function filterReducer(predicateFn,combinationFn) {
    return function reducer(list,val){
        if (predicateFn( val )) return combinationFn( list, val );
        return list;
    };
}

使用这种形式的辅助函数:

var strToUppercaseReducer = mapReducer( strUppercase, listCombination );
var isLongEnoughReducer = filterReducer( isLongEnough, listCombination );
var isShortEnoughReducer = filterReducer( isShortEnough, listCombination );

将这些实用函数定义为接收两个参数而不是一个参数不太方便组合,因此我们使用我们的 curry(..) (柯里化)方法:

var curriedMapReducer = curry( function mapReducer(mapperFn,combinationFn){
    return function reducer(list,val){
        return combinationFn( list, mapperFn( val ) );
    };
} );

var curriedFilterReducer = curry( function filterReducer(predicateFn,combinationFn){
    return function reducer(list,val){
        if (predicateFn( val )) return combinationFn( list, val );
        return list;
    };
} );

var strToUppercaseReducer =
    curriedMapReducer( strUppercase )( listCombination );
var isLongEnoughReducer =
    curriedFilterReducer( isLongEnough )( listCombination );
var isShortEnoughReducer =
    curriedFilterReducer( isShortEnough )( listCombination );

这看起来有点冗长而且可能不是很有用。

但这实际上是我们进行下一步推导的必要条件。请记住,我们的最终目标是能够 compose(..) 这些 reducers。我们快要完成了。

 附录 A:Transducing(下)---- 四天后更新

** 【上一章】[翻译连载 | 第 11 章:融会贯通 -《JavaScript轻量级函数式编程》 |《你不知道的JS》姊妹篇
](https://juejin.im/post/5a0cf1... **

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。

iKcamp官网:https://www.ikcamp.com
访问官网更快阅读全部免费分享课程:
《iKcamp出品|全网最新|微信小程序|基于最新版1.0开发者工具之初中级培训教程分享》
《iKcamp出品|基于Koa2搭建Node.js实战项目教程》
包含:文章、视频、源代码

查看原文

dongzhe3917875 收藏了文章 · 9月4日

d3-force 力导图 源码解读与原理分析【二 : 四叉树(一)】

我们在上文源码解析发现v4版的节点碰撞采用四叉树进行了优化。
那么V4版本的力导图具体和v3版的有何不同点呢,四叉树又如何优化碰撞校验的呢?

v3-force VS v4-force

https://github.com/xswei/D3-V... (原文链接)

d3.layout.force被重命名为d3.forceSimulation。新的力导向仿真使用速度Verlet算法而不是位置Verlet算法,即追踪节点的位置(node.x,node.y)和速度(node.vx,node.vy)而不是之前的位置(node.px,node.py)。

现在的力导向仿真可以很好的扩展:你可以指定哪些受力。这个方法使得作品更柔和。新的力导向也更灵活:可以为每个节点和连接设置参数。可以指定单独的x和y来代替force.gravity。新的link force代替force.linkStrength并且更稳定。新的many-body force代替原有的force.charge并且支持最小距离参数。性能的提高归功于4.0的新的四叉树。4.0提供了冲突解决和向中对齐的方法。

新的力导向仿真避免不确定性,比如在3.x中结点的初始位置是随机的,如果结点没有初始位置,则被放置在一个类似叶序图案上。
clipboard.png
力导向仿真提供了一些方法来控制结点"过热"(根本停不下来那种),比如simulation.alphaMin和simulation.alphaDecay和内部计时器。调用simulation.alpha时对内部计时器没有影响,它由simulation.stop和simulatonrestart独立控制。与3.x一样,4.0使用simulation.tick来打点。force.frition由simulation.velocityDecay代替。新的simulation.alphaTarget方法允许设置期望的仿真"温度"(什么时候停下来)。这样可以使仿真重新开始然后再次冷却,提高了交互过程中的稳定性。

force布局不再依赖拖拽行为,因为你可以直接创建一个可拖动的力导向布局。设置node.fx和node.fy来修正节点的位置。simulation.find方法替代了泰森多边形的SVG叠加,以找到最近节点的引用。

四叉树是什么鬼

四叉树(quad-tree)是一种数据结构,是一种每个节点最多有四个子树的数据结构。

clipboard.png

四叉树的定义是:它的每个节点下至多可以有四个子节点,通常把一部分二维空间细分为四个象限或区域并把该区域里的相关信息存入到四叉树节点中。

四叉树可以用来做什么

  1. 用来在数据库中放置和定位文件(称作记录或键)

  2. 2D空间碰撞校验

  3. 地理空间划分常用于GIS查询

  4. 图像处理

基于四叉树2D空间碰撞校验

d3.v4里的force就是使用到四叉树的碰撞校验。该方法也经常被游戏领域使用到。

我们来观察一下:每个实体根据他在2D空间的位置而被放入这些子节点(正方形区域)中的一个里。任何不能正好在一个节点区域内的物体会被放在父节点。完全处于一个子区域内的点是不会与另一个区域的点碰撞的,这使得我们在做碰撞校验或者获取相邻的节点时成倍的减少校验计算。

clipboard.png

以上图为例,存储方式有多种。存储的最大差异在意当实体坐标位于区域边上的时候,该实体应存储在哪个位置。

存储方法一:

clipboard.png
那么所有实体只能与以自己为跟节点的树的所有节点上的实体才有可能发生碰撞。

存储方法二:

clipboard.png
clipboard.png

同理,实体只能与自身所在象限的其他实体发生碰撞(这里暂时先不考虑碰撞半径)。

d3-quadtree 的四叉树

API地址:https://github.com/d3/d3-quad...

中文地址:https://github.com/Leannechn/...

代码试运行地址:https://runkit.com/npm/d3-qua...

d3-quadtree采用的第二种存储方式,为了避免浮点计算的精确度问题,最小区域单位为1
创建只有一个实体的四叉树
clipboard.png

// 测试代码二
var d3Quadtree = require("d3-quadtree")
var data = [[1.1,1.2]];
var tree = d3Quadtree.quadtree().addAll(data);

这里我们要说明几个变量和函数名的含义

Quadtree.extend() // [[x0,y0],[x1,y1]]四叉树的边界,即矩形的左上顶点的坐标,与右下顶点坐标
Quadtree.x0 // 正方形区域左上顶点坐标x
Quadtree.y0 // 正方形区域左上顶点坐标y
Quadtree.x1 // 正方形区域右下顶点坐标x
Quadtree.y1 // 正方形区域右下顶点坐标y

clipboard.png

                                        root
                             /                  \
         第一象限:_root[0]                      第三象限:_root[2]==> [2,6]
         /                   \
        /                     \

第一象限:_root0-->[1.1,1.2] 第二象限:_root0-->[3,1]

在理解了四叉树的存储之后,我们在看d3-quadtree的API。是否一目了然了呢!
下文我们来看看d3-quadtree的源码。

1.1

查看原文

dongzhe3917875 赞了文章 · 6月18日

在javascript中安全地访问深层嵌套的值

介绍

这是一篇短文,旨在展示多种在javascript中安全地访问深层嵌套值的方式。
下面的例子通过不同的方式来解决这一问题。

开始之前,让我们看下实际遇到这种状况时..

假设有一个props对象(如下),当我们需要获取user对象的posts的第一条的comments对象,通常会如何操作?

const props = {
  user: {
    posts: [
      { title: 'Foo', comments: [ 'Good one!', 'Interesting...' ] },
      { title: 'Bar', comments: [ 'Ok' ] },
      { title: 'Baz', comments: [] },
    ]
  }
}
// access deeply nested values...
props.user &&
props.user.posts &&
props.user.posts[0] &&
props.user.posts[0].comments

最直白的方式是确保每一个key或index的存在再去访问它的下一级。考虑的多点,当需求变化需要去请求第一条comments时,这个式子会变得越来越长。

// updating the previous example...
props.user &&
props.user.posts &&
props.user.posts[0] &&
props.user.posts[0].comments &&
props.user.posts[0].comments[0]

所以每次我们想要访问深度嵌套的数据时,都必须明确地进行手动检查。或许很难说清这一点,试想一下当我们不希望检验users对象下的posts,只是希望获取到users下的最后一条comment,和前面的解决思路是相违背的。

这个例子可能有些夸张,但你懂我的意思,为了得到深层嵌套的值,我们需要检验整个结构的每一级(所有父级)。

所以,现在我们已经更好地理解了实际想要解决的问题,让我们来看看不同的解决方案。前面一些是通过javascript,再后面通过Ramda,再再后面是Ramda和Folktale。将通过一些比较有趣并且不算高级的例子来说明,希望大家在本次专题里有所收益。

JavaScript

首先,我们不希望手动检验每一级是否为空或是未定义,我们希望有一种精简且灵活的方式来应对各种数据源。

const get = (p, o) =>
  p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o)
// let's pass in our props object...
console.log(get(['user', 'posts', 0, 'comments'], props))
// [ 'Good one!', 'Interesting...' ]
console.log(get(['user', 'post', 0, 'comments'], props))
// null

看一下get这个方法

const get = (p, o) =>
  p.reduce((xs, x) =>
    (xs && xs[x]) ? xs[x] : null, o)

我们传入路径(path)作为第一个参数,需要获取的对象(object)作为第二个参数。
思考一下这第二个参数o(object),你可能会问自己:我们期望这个方法有什么功能?应该是一个输入特定路径并且针对任何对象都能返回是否存在预期对象的方法。

const get = p => o =>
  p.reduce((xs, x) =>
    (xs && xs[x]) ? xs[x] : null, o)

const getUserComments = get(['user', 'posts', 0, 'comments'])

通过这种方式,我们可以调用getUserComments和之前的props对象或是任何其他对象。这也暗示我们必须得像这样不停琢磨这个get函数,

最终我们能打印出结果,验证下是否如预期得结果。

console.log(getUserComments(props))
// [ 'Good one!', 'Interesting...' ]
console.log(getUserComments({user:{posts: []}}))
// null

get函数实质上就是在减少先前的路径。

让我们来简化一下,现在我们只想访问这个id。

['id'].reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, {id: 10})

我们用提供的对象初始化reduce函数,每一层通过(xs && xs[x]) 检验对象是否被定义且有效, 然后依次递归或是返回null退出。
就像上面的例子一样,我们可以轻巧地解决这一问题。当然如果你偏向习惯用字符串路径而不是数组来表达路径,还需要对get函数做一些小改动,我将留给感兴趣的读者来实现。

Ramda

我们也可以利用Ramda函数库来实现相同的功能,而不是编写自己的函数。
Ramda提供了一个path方法,两个参数输入, path以及object。让我们用Ramda重写这个例子。

const getUserComments = R.path(['user', 'posts', 0, 'comments'])

现在通过getUserComments传入数据源就能得到我们希望的值,如果没有找到就会得到null

getUserComments(props) // [ 'Good one!', 'Interesting...' ]
getUserComments({}) // null

但是如果我们想要返回的无效值不是null呢?Ramda提供了pathOrpathOr需要传入默认值作为参数。

const getUserComments = R.pathOr([], ['user', 'posts', 0, 'comments'])
getUserComments(props) // [ 'Good one!', 'Interesting...' ]
getUserComments({}) // []

感谢Gleb Bahmutov提供对于path和pathOr的见解。

Ramda + Folktale

让我们再加入FolktaleMaybe。例如我们可以构建一个更通用的getPath函数(同样传入path和object)。

const getPath = R.compose(Maybe.fromNullable, R.path)
const userComments =
  getPath(['user', 'posts', 0, 'comments'], props)

调用getPath会返回Maybe.Just或是Maybe.Nothing

console.log(userComments) // Just([ 'Good one!', 'Interesting...' ])

将我们的返回结果包在Maybe中有什么用呢?通过采用这种方式,我们可以安全地使用userComments,无需手动检验userComments是否返回nul。

console.log(userComments.map(x => x.join(',')))
// Just('Good one!,Interesting...')

没有任何值时也是如此。

const userComments =
    getPath(['user', 'posts', 8, 'title'], props)

console.log(userComments.map(x => x.join(',')).toString())
// Nothing

我们可以把所有属性包裹在Maybe内。这使我们能够使用composeK来实现链式调用。

// example using composeK to access a deeply nested value.
const getProp = R.curry((name, obj) =>
  Maybe.fromNullable(R.prop(name, obj)))
const findUserComments = R.composeK(
  getProp('comments'),
  getProp(0),
  getProp('posts'),
  getProp('user')
)
console.log(findUserComments(props).toString())
// Just([ 'Good one!', 'Interesting...' ])
console.log(findUserComments({}).toString())
// Nothing

这种方式是非常前卫的,使用Ramda地path方法其实就足够了。不过让我简单看下下面这个例子(通过Ramda地composechain实现同样的效果)

// using compose and chain
const getProp = R.curry((name, obj) =>
  Maybe.fromNullable(R.prop(name, obj)))
const findUserComments =
  R.compose(
    R.chain(getProp('comments')),
    R.chain(getProp(0)),
    R.chain(getProp('posts')),
    getProp('user')
  )
console.log(findUserComments(props).toString())
// Just([ 'Good one!', 'Interesting...' ])
console.log(findUserComments({}).toString())
// Nothing

通过pipeK也能实现同样的效果。

// example using pipeK to access a deeply nested value.
const getProp = R.curry((name, obj) =>
  Maybe.fromNullable(R.prop(name, obj)))
const findUserComments = R.pipeK(
  getProp('user'),
  getProp('posts'),
  getProp(0),
  getProp('comments')
)
console.log(findUserComments(props).toString())
// Just([ 'Good one!', 'Interesting...' ])
console.log(findUserComments({}).toString())
// Nothing

还可以用map配合pipeK。感谢Tom Harding提供pipeK的例子。

Lenses

最后,我们还可以使用LensesRamda就带有lensProplensPath

// lenses
const findUserComments =
  R.lensPath(['user', 'posts', 0, 'comments'])
console.log(R.view(findUserComments, props))
// [ 'Good one!', 'Interesting...' ]

总结

我们应该对如何检索嵌套数据的多种方法有了清楚的理解。除了知道如何自己实现外,还应该对Ramda提供的关于这个问题的功能有一个基本的了解。甚至可以更好地i理解为什么将结果包含Either或Maybe中。我们还触及了Lenses,可以更新深度嵌套数据而不会改变对象。
最后,你再也不会去编写下面这样的代码了。

// updating the previous example...
props.user &&
props.user.posts &&
props.user.posts[0] &&
props.user.posts[0].comments &&
props.user.posts[0].comments[0]
查看原文

赞 2 收藏 0 评论 0

dongzhe3917875 收藏了文章 · 5月13日

全面拥抱React-Hooks

丑话说在前面
强烈建议至少刷一遍《官方文档》,反复研读《Hooks FAQ》
这里主要以本人关注点聚合,方便理解用于实践

一、React-Hooks要解决什么?

以下是上一代标准写法类组件的缺点,也正是hook要解决的问题

  • 大型组件很难拆分和重构,也很难测试。
  • 业务逻辑分散在组件的各个方法之中,导致重复逻辑或关联逻辑。
  • 组件类引入了复杂的编程模式,比如 Render props 和高阶组件

设计目的

  • 加强版函数组件,完全不使用"类",就能写出一个全功能的组件
  • 组件尽量写成纯函数,如果需要外部功能和副作用,就用钩子把外部代码"钩"进来

二、如何用好React-Hooks?

明确几点概念

  • 所有的hook,在默认没有依赖项数组每次渲染都会更新
  • 每次 Render 时Props、State、事件处理、Effect等hooks都遵循 Capture Value 的特性
  • Render时会注册各种变量,函数包括hooks,N次Render就会有N个互相隔离的状态作用域
  • 如果你的useEffect依赖数组为[],那么它初始化一次,且使用的state,props等永远是他初始化时那一次Render保存下来的值
  • React 会确保 setState,dispatch,context 函数的标识是稳定的,可以安全地从 hooks 的依赖列表中省略

Function Component中每次Render都会形成一个快照并保留下来,这样就确保了状态可控,hook默认每次都更新,会导致重复请求等一系列问题,如果给[]就会一尘不变,因此用好hooks最重要就是学会控制它的变化

三、一句话概括Hook API

  • useState 异步设置更新state
  • useEffect 处理副作用(请求,事件监听,操作DOM等)
  • useContext 接收一个 context 对象并返回该 context 的当前值
  • useReducer 同步处理复杂state,减少了对深层传递回调的依赖
  • useCallback 返回一个 memoized 回调函数,避免非必要渲染
  • useMemo 返回一个 memoized 值,使得控制具体子节点何时更新变得更容易,减少了对纯组件的需要,可替代shouldComponentUpdate
  • useRef 返回一个在组件的整个生命周期内保持不变 ref 对象,其 .current 属性是可变的,可以绕过 Capture Value 特性
  • useLayoutEffect 其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect
  • useImperativeHandle 应当与 forwardRef 一起使用,将 ref 自定义暴露给父组件的实例值
  • useDebugValue 可用于在 React 开发者工具中显示自定义 hook 的标签

四、关注异同点

useState 与 this.setState

  • 相同点:都是异步的,例如在 onClick 事件中,调用两次 setState,数据只改变一次。
  • 不同点:类中的 setState 是合并,而useState中的 setState 是替换。

useState 与 useReducer

  • 相同点:都是操作state
  • 不同点:使用 useState 获取的 setState 方法更新数据时是异步的;而使用 useReducer 获取的 dispatch 方法更新数据是同步的。
  • 推荐:当 state 状态值结构比较复杂时,使用useReducer

useLayoutEffect 与 useEffect

  • 相同点:都是在浏览器完成布局与绘制之后执行副作用操作
  • 不同点:useEffect 会延迟调用,useLayoutEffect 会同步调用阻塞视觉更新,可以使用它来读取 DOM 布局并同步触发重渲染
  • 推荐:一开始先用 useEffect,只有当它出问题的时候再尝试使用 useLayoutEffect

useCallback 与 useMemo

  • 相同点:都是返回memoized,useCallback( fn, deps) 相当于 useMemo( ( ) => fn, deps)
  • 不同点:useMemo返回缓存的变量,useCallback返回缓存的函数
  • 推荐:不要过早的性能优化,搭配食用口味更佳(详见下文性能优化)

五、性能优化

在大部分情况下我们只要遵循 React 的默认行为,因为 React 只更新改变了的 DOM 节点,不过重新渲染仍然花费了一些时间,除非它已经慢到让人注意了

react中性能的优化点在于:

  • 1、调用setState,就会触发组件的重新渲染,无论前后的state是否不同
  • 2、父组件更新,子组件也会自动的更新

之前的解决方案

基于上面的两点,我们通常的解决方案是:

  • 使用immutable进行比较,在不相等的时候调用setState;
  • 在 shouldComponentUpdate 中判断前后的 props和 state,如果没有变化,则返回false来阻止更新。
  • 使用 React.PureComponent

使用hooks function之后的解决方案

传统上认为,在 React 中使用内联函数对性能的影响,与每次渲染都传递新的回调会如何破坏子组件的 shouldComponentUpdate 优化有关, 使用useCallback缓存函数引用,再传递给经过优化的并使用引用相等性去避免非必要渲染的子组件时,它将非常有用

  • 1、使用 React.memo等效于 PureComponent,但它只比较 props,且返回值相反,true才会跳过更新
const Button = React.memo((props) => {
  // 你的组件
}, fn);// 也可以自定义比较函数
  • 2、用 useMemo 优化每一个具体的子节点(详见实践3)
  • 3、useCallback Hook 允许你在重新渲染之间保持对相同的回调引用以使得 shouldComponentUpdate 继续工作(详见实践3)
  • 4、useReducer Hook 减少了对深层传递回调的依赖(详见实践2)

如何惰性创建昂贵的对象?

  • 当创建初始 state 很昂贵时,我们可以传一个 函数 给 useState 避免重新创建被忽略的初始 state
function Table(props) {
  // ⚠️ createRows() 每次渲染都会被调用
  const [rows, setRows] = useState(createRows(props.count));
  // ...
  // ✅ createRows() 只会被调用一次
  const [rows, setRows] = useState(() => createRows(props.count));
  // ...
}
  • 避免重新创建 useRef() 的初始值,确保某些命令式的 class 实例只被创建一次:
function Image(props) {
  // ⚠️ IntersectionObserver 在每次渲染都会被创建
  const ref = useRef(new IntersectionObserver(onIntersect));
  // ...
}
function Image(props) {
  const ref = useRef(null);
  // ✅ IntersectionObserver 只会被惰性创建一次
  function getObserver() {
    if (ref.current === null) {
      ref.current = new IntersectionObserver(onIntersect);
    }
    return ref.current;
  }
  // 当你需要时,调用 getObserver()
  // ...
}

六、注意事项

Hook 规则

  • 在最顶层使用 Hook
  • 只在 React 函数中调用 Hook,不要在普通的 JavaScript 函数中调用
  • 将条件判断放置在 hook 内部
  • 所有 Hooks 必须使用 use 开头,这是一种约定,便于使用 ESLint 插件 来强制 Hook 规范 以避免 Bug;
useEffect(function persistForm() {
  if (name !== '') {
    localStorage.setItem('formData', name);
  }
});

告诉 React 用到了哪些外部变量,如何对比依赖

useEffect(() => {
  document.title = "Hello, " + name;
}, [name]); // 以useEffect为示例,适用于所有hook

直到 name 改变时的 Rerender,useEffect 才会再次执行,保证了性能且状态可控

不要在hook内部set依赖变量,否则你的代码就像旋转的菊花一样停不下来

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);// 以useEffect为示例,适用于所有hook

不要在useMemo内部执行与渲染无关的操作

  • useMemo返回一个 memoized 值,把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值,避免在每次渲染时都进行高开销的计算。
  • 传入 useMemo 的函数会在渲染期间执行,请不要在这个函数内部执行与渲染无关的操作。
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

七、实践场景示例

实际应用场景往往不是一个hook能搞定的,长篇大论未必说的清楚,直接上例子(来源于官网摘抄,网络收集,自我总结)

1、只想执行一次的 Effect 里需要依赖外部变量

【将更新与动作解耦】-【useEffect,useReducer,useState】

  • 1-1、使用setState的函数式更新解决依赖一个变量

该函数将接收先前的 state,并返回一个更新后的值

useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);
  • 1-2、使用useReducer解决依赖多个变量
import React, { useReducer, useEffect } from "react";

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}
export default function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;
  console.log(count);
  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        });
      }} />
    </>
  );
}

2、大型的组件树中深层传递回调

【通过 context 往下传一个 dispatch 函数】-【createContext,useReducer,useContext】

/**index.js**/
import React, { useReducer } from "react";
import Count from './Count'
export const StoreDispatch = React.createContext(null);
const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  switch (action.type) {
    case 'tick':
      return { count: count + step, step };
    case 'step':
      return { count, step: action.step };
    default:
      throw new Error();
  }
}
export default function Counter() {
  // 提示:`dispatch` 不会在重新渲染之间变化
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <StoreDispatch.Provider value={dispatch}>
      <Count state={state} />
    </StoreDispatch.Provider>
  );
}

/**Count.js**/
import React, { useEffect,useContext }  from 'react';
import {StoreDispatch} from '../index'
import styles from './index.css';

export default function(props) {
  const { count, step } = props.state;
  const dispatch = useContext(StoreDispatch);
  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);
  return (
    <div className={styles.normal}>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        });
      }} />
    </div>
  );
}

3、代码内聚,更新可控

【层层依赖,各自管理】-【useEffect,useCallback,useContext】

function App() {
  const [count, setCount] = useState(1);
  const countRef = useRef();// 在组件生命周期内保持唯一实例,可穿透闭包传值

  useEffect(() => {
    countRef.current = count; // 将 count 写入到 ref
  });
  // 只有countRef变化时,才会重新创建函数
  const callback = useCallback(() => {
    const currentCount = countRef.current //保持最新的值
    console.log(currentCount);
  }, [countRef]);
  return (
    <Parent callback={callback} count={count}/>
  )
}
function Parent({ count, callback }) {
  // count变化才会重新渲染
  const child1 = useMemo(() => <Child1 count={count} />, [count]);
  // callback变化才会重新渲染,count变化不会 Rerender
  const child2 = useMemo(() => <Child2 callback={callback} />, [callback]);
  return (
    <>
      {child1}
      {child2}
    </>
  )
}

八、自定义 HOOK

实现this.setState的callback

function useStateCallback(init) {
  const [state, setState] = useState(init)
  const ref = useRef(init)

  const handler = useCallback((value, cb) => {
    setState(value)
    if(cb) {
      ref.current = value
      cb(ref.current)
    }
  }, [setState])

  return [state, handler]
}

获取上一轮的 props 或 state

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

只在更新时运行 effect

function useUpdate(fn) {
  const mounting = useRef(true);
  useEffect(() => {
    if (mounting.current) {
      mounting.current = false;
    } else {
      fn();
    }
  });
}

组件是否销毁

function useIsMounted(fn) {
  const [isMount, setIsMount] = useState(false);
  useEffect(() => {
    if (!isMount) {
      setIsMount(true);
    }
    return () => setIsMount(false);
  }, []);
  return isMount;
}

惰性初始化useRef

function useInertRef(obj) { // 传入一个实例 new IntersectionObserver(onIntersect)
  const ref = useRef(null);
  if (ref.current === null) {
  // ✅ IntersectionObserver 只会被惰性创建一次
    ref.current = obj;
  }
  return ref.current;
}

参考文章

  1. 精读《useEffect 完全指南》
  2. 精读《Function VS Class 组件》
  3. 精读《怎么用 React Hooks 造轮子》
  4. 《React Hooks 使用详解》
  5. 《useMemo与useCallback使用指南》
查看原文

dongzhe3917875 收藏了文章 · 5月5日

useTypescript-React Hooks和TypeScript完全指南

引言

React v16.8 引入了 Hooks,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。这些功能可以在应用程序中的各个组件之间使用,从而易于共享逻辑。Hook 令人兴奋并迅速被采用,React 团队甚至想象它们最终将替换类组件。

以前在 React 中,共享逻辑的方法是通过高阶组件和 props 渲染。Hooks 提供了一种更简单方便的方法来重用代码并使组件可塑形更强。

本文将展示 TypeScript 与 React 集成后的一些变化,以及如何将类型添加到 Hooks 以及你的自定义 Hooks 上。

引入 Typescript 后的变化

有状态组件(ClassComponent)

API 对应为:

React.Component<P, S>

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

以下是官网的一个例子,创建 Props 和 State 接口,Props 接口接受 name 和 enthusiasmLevel 参数,State 接口接受 currentEnthusiasm 参数:

import * as React from "react";

export interface Props {
  name: string;
  enthusiasmLevel?: number;
}

interface State {
  currentEnthusiasm: number;
}

class Hello extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { currentEnthusiasm: props.enthusiasmLevel || 1 };
  }

  onIncrement = () => this.updateEnthusiasm(this.state.currentEnthusiasm + 1);
  onDecrement = () => this.updateEnthusiasm(this.state.currentEnthusiasm - 1);

  render() {
    const { name } = this.props;

    if (this.state.currentEnthusiasm <= 0) {
      throw new Error('You could be a little more enthusiastic. :D');
    }

    return (
      <div className="hello">
        <div className="greeting">
          Hello {name + getExclamationMarks(this.state.currentEnthusiasm)}
        </div>
        <button onClick={this.onDecrement}>-</button>
        <button onClick={this.onIncrement}>+</button>
      </div>
    );
  }

  updateEnthusiasm(currentEnthusiasm: number) {
    this.setState({ currentEnthusiasm });
  }
}

export default Hello;

function getExclamationMarks(numChars: number) {
  return Array(numChars + 1).join('!');
}

TypeScript 可以对 JSX 进行解析,充分利用其本身的静态检查功能,使用泛型进行 Props、 State 的类型定义。定义后在使用 this.state 和 this.props 时可以在编辑器中获得更好的智能提示,并且会对类型进行检查。

react 规定不能通过 this.props.xxx 和 this.state.xxx 直接进行修改,所以可以通过 readonly 将 State 和 Props 标记为不可变数据:

interface Props {
  readonly number: number;
}

interface State {
  readonly color: string;
}

export class Hello extends React.Component<Props, State> {
  someMethod() {
    this.props.number = 123; // Error: props 是不可变的
    this.state.color = 'red'; // Error: 你应该使用 this.setState()
  }
}

无状态组件(StatelessComponent)

API 对应为:

// SFC: stateless function components
const List: React.SFC<IProps> = props => null
// v16.8起,由于hooks的加入,函数式组件也可以使用state,所以这个命名不准确。新的react声明文件里,也定义了React.FC类型^_^
React.FunctionComponent<P> or React.FC<P>。

const MyComponent: React.FC<Props> = ...

无状态组件也称为傻瓜组件,如果一个组件内部没有自身的 state,那么组件就可以称为无状态组件。在@types/react已经定义了一个类型type SFC<P = {}> = StatelessComponent

先看一下之前无状态组件的写法:

import React from 'react'

const Button = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
)

如果采用 ts 来编写出来的无状态组件是这样的:

import React, { MouseEvent, SFC } from 'react';

type Props = { onClick(e: MouseEvent<HTMLElement>): void };

const Button: SFC<Props> = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
);

事件处理

我们在进行事件注册时经常会在事件处理函数中使用 event 事件对象,例如当使用鼠标事件时我们会通过 clientX、clientY 去获取指针的坐标。

大家可以想到直接把 event 设置为 any 类型,但是这样就失去了我们对代码进行静态检查的意义。

function handleMouseChange (event: any) {
  console.log(event.clientY)
}

试想下当我们注册一个 Touch 事件,然后错误的通过事件处理函数中的 event 对象去获取其 clientY 属性的值,在这里我们已经将 event 设置为 any 类型,导致 TypeScript 在编译时并不会提示我们错误, 当我们通过 event.clientY 访问时就有问题了,因为 Touch 事件的 event 对象并没有 clientY 这个属性。

通过 interface 对 event 对象进行类型声明编写的话又十分浪费时间,幸运的是 React 的声明文件提供了 Event 对象的类型声明。

  • 通用的 React Event Handler

API 对应为:

React.ReactEventHandler<HTMLElement>

简单的示例:

const handleChange: React.ReactEventHandler<HTMLInputElement> = (ev) => { ... }

<input onChange={handleChange} ... />
  • 特殊的 React Event Handler

常用 Event 事件对象类型:

ClipboardEvent<T = Element> 剪贴板事件对象


DragEvent<T = Element> 拖拽事件对象


ChangeEvent<T = Element>  Change 事件对象


KeyboardEvent<T = Element> 键盘事件对象


MouseEvent<T = Element> 鼠标事件对象


TouchEvent<T = Element>  触摸事件对象


WheelEvent<T = Element> 滚轮事件对象


AnimationEvent<T = Element> 动画事件对象


TransitionEvent<T = Element> 过渡事件对象

简单的示例:

const handleChange = (ev: React.MouseEvent<HTMLDivElement>) => { ... }

<div onMouseMove={handleChange} ... />

React 元素

API 对应为:

React.ReactElement<P> or JSX.Element

简单的示例:

// 表示React元素概念的类型: DOM元素组件或用户定义的复合组件
const elementOnly: React.ReactElement = <div /> || <MyComponent />;

React Node

API 对应为:

React.ReactNode

表示任何类型的 React 节点(基本上是 ReactElement + 原始 JS 类型的合集)

简单的示例:

const elementOrComponent: React.ReactNode = 'string' || 0 || false || null || undefined || <div /> || <MyComponent />;

React CSS 属性

API 对应为:

React.CSSProperties

用于标识 jsx 文件中的 style 对象(通常用于 css-in-js

简单的示例:

const styles: React.CSSProperties = { display: 'flex', ...
const element = <div style={styles} ...

Hooks 登场

首先,什么是 Hooks 呢?

React 一直都提倡使用函数组件,但是有时候需要使用 state 或者其他一些功能时,只能使用类组件,因为函数组件没有实例,没有生命周期函数,只有类组件才有。

Hooks 是 React 16.8 新增的特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

默认情况下,React 包含 10 个钩子。其中 3 个挂钩被视为是最常使用的“基本”或核心挂钩。还有 7 个额外的“高级”挂钩,这些挂钩最常用于边缘情况。10 个钩子如下:

  • 基础

    • useState
    • useEffect
    • useContext
  • 高级

    • useReducer
    • useCallback
    • useMemo
    • useRef
    • useImperativeHandle
    • useLayoutEffect
    • useDebugValue

useState with TypeScript

API 对应为:

// 传入唯一的参数: initialState,可以是数字,字符串等,也可以是对象或者数组。
// 返回的是包含两个元素的数组:第一个元素,state 变量,setState 修改 state值的方法。
const [state, setState] = useState(initialState);

useState是一个允许我们替换类组件中的 this.state 的挂钩。我们执行该挂钩,该挂钩返回一个包含当前状态值和一个用于更新状态的函数的数组。状态更新时,它会导致组件的重新 render。下面的代码显示了一个简单的 useState 钩子:

import * as React from 'react';

const MyComponent: React.FC = () => {
  const [count, setCount] = React.useState(0);
  return (
    <div onClick={() => setCount(count + 1)}>
      {count}
    </div>
  );
};

useEffect with TypeScript

API 对应为:

// 两个参数
// 第一个是一个函数,是在第一次渲染(componentDidMount)以及之后更新渲染之后会进行的副作用。这个函数可能会有返回值,倘若有返回值,返回值也必须是一个函数,会在组件被销毁(componentWillUnmount)时执行。
// 第二个参数是可选的,是一个数组,数组中存放的是第一个函数中使用的某些副作用属性。用来优化 useEffect
useEffect(() => { // 需要在componentDidMount执行的内容 return function cleanup() { // 需要在componentWillUnmount执行的内容 } }, [])

useEffect是用于我们管理副作用(例如 API 调用)并在组件中使用 React 生命周期的。useEffect 将回调函数作为其参数,并且回调函数可以返回一个清除函数(cleanup)。回调将在第一次渲染(componentDidMount) 和组件更新时(componentDidUpate)内执行,清理函数将组件被销毁(componentWillUnmount)内执行。

useEffect(() => {
  // 给 window 绑定点击事件
  window.addEventListener('click', handleClick);

  return () => {
      // 给 window 移除点击事件
      window.addEventListener('click', handleClick);
  }
});

默认情况下,useEffect 将在每个渲染时被调用,但是你还可以传递一个可选的第二个参数,该参数仅允许您在 useEffect 依赖的值更改时或仅在初始渲染时执行。第二个可选参数是一个数组,仅当其中一个值更改时才会 reRender(重新渲染)。如果数组为空,useEffect 将仅在 initial render(初始渲染)时调用。

useEffect(() => {
  // 使用浏览器API更新文档标题
  document.title = `You clicked ${count} times`;
}, [count]);    // 只有当数组中 count 值发生变化时,才会执行这个useEffect。

useContext with TypeScript

useContext允许您利用React context这样一种管理应用程序状态的全局方法,可以在任何组件内部进行访问而无需将值传递为 props。

useContext 函数接受一个 Context 对象并返回当前上下文值。当提供程序更新时,此挂钩将触发使用最新上下文值的重新渲染。

import { createContext, useContext } from 'react';

props ITheme {
  backgroundColor: string;
  color: string;
}

const ThemeContext = createContext<ITheme>({
  backgroundColor: 'black',
  color: 'white',
})

const themeContext = useContext<ITheme>(ThemeContext);

useReducer with TypeScript

对于更复杂的状态,您可以选择将该 useReducer 函数用作的替代 useState。

const [state,dispatch] =  useReducer(reducer,initialState,init);

如果您以前使用过Redux,则应该很熟悉。useReducer接受 3 个参数(reducer,initialState,init)并返回当前的 state 以及与其配套的 dispatch 方法。reducer 是如下形式的函数(state, action) => newState;initialState 是一个 JavaScript 对象;而 init 参数是一个惰性初始化函数,可以让你延迟加载初始状态。

这听起来可能有点抽象,让我们看一个实际的例子:

const initialState = 0;
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {number: state.number + 1};
    case 'decrement':
      return {number: state.number - 1};
    default:
      throw new Error();
  }
}
function init(initialState){
    return {number:initialState};
}
function Counter(){
    const [state, dispatch] = useReducer(reducer, initialState,init);
    return (
        <>
          Count: {state.number}
          <button onClick={() => dispatch({type: 'increment'})}>+</button>
          <button onClick={() => dispatch({type: 'decrement'})}>-</button>
        </>
    )
}

看完例子再结合上面 useReducer 的 api 是不是立马就明白了呢?

useCallback with TypeScript

useCallback 钩子返回一个 memoized 回调。这个钩子函数有两个参数:第一个参数是一个内联回调函数,第二个参数是一个数组。数组将在回调函数中引用,并按它们在数组中的存在顺序进行访问。

const memoizedCallback =  useCallback(()=> {
    doSomething(a,b);
  },[ a,b ],);

useCallback 将返回一个记忆化的回调版本,它仅会在某个依赖项改变时才重新计算 memoized 值。当您将回调函数传递给子组件时,将使用此钩子。这将防止不必要的渲染,因为仅在值更改时才执行回调,从而可以优化组件。可以将这个挂钩视为与shouldComponentUpdate生命周期方法类似的概念。

useMemo with TypeScript

useMemo返回一个 memoized 值。 传递“创建”函数和依赖项数组。useMemo 只会在其中一个依赖项发生更改时重新计算 memoized 值。此优化有助于避免在每个渲染上进行昂贵的计算。

const memoizedValue =  useMemo(() =>  computeExpensiveValue( a, b),[ a, b ]);
useMemo 在渲染过程中传递的函数会运行。不要做那些在渲染时通常不会做的事情。例如,副作用属于 useEffect,而不是 useMemo。

看到这,你可能会觉得,useMemouseCallback的作用有点像啊,那它们之间有什么区别呢?

  • useCallback 和 useMemo 都可缓存函数的引用或值。
  • 从更细的使用角度来说 useCallback 缓存函数的引用,useMemo 缓存计算数据的值。

useRef with TypeScript

useRef挂钩允许你创建一个 ref 并且允许你访问基础 DOM 节点的属性。当你需要从元素中提取值或获取与 DOM 相关的元素信息(例如其滚动位置)时,可以使用此方法。

const refContainer  =  useRef(initialValue);

useRef 返回一个可变的 ref 对象,其.current属性被初始化为传递的参数(initialValue)。返回的对象将存留在整个组件的生命周期中。

function TextInputWithFocusButton() {
  const inputEl = useRef<HTMLInputElement>(null);
  const onButtonClick = () => {
    inputEl.current.focus();
  };

  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useImperativeHandle with TypeScript

useImperativeHandle可以让你在使用 ref 时,自定义暴露给父组件的实例值。

useImperativeHandle(ref, createHandle, [inputs])

useImperativeHandle 钩子函数接受 3 个参数: 一个 React ref、一个 createHandle 函数和一个用于暴露给父组件参数的可选数组。

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = React.forwardRef(FancyInput);

const fancyInputRef = React.createRef();
<FancyInput ref={fancyInputRef}>Click me!</FancyInput>;

useLayoutEffect with TypeScript

与 useEffect Hooks 类似,都是执行副作用操作。但是它是在所有 DOM 更新完成后触发。可以用来执行一些与布局相关的副作用,比如获取 DOM 元素宽高,窗口滚动距离等等。

useLayoutEffect(() => { doSomething });
进行副作用操作时尽量优先选择 useEffect,以免阻止视图更新。与 DOM 无关的副作用操作请使用 useEffect。
import React, { useRef, useState, useLayoutEffect } from 'react';

export default () => {

    const divRef = useRef(null);

    const [height, setHeight] = useState(50);

    useLayoutEffect(() => {
        // DOM 更新完成后打印出 div 的高度
        console.log('useLayoutEffect: ', divRef.current.clientHeight);
    })

    return <>
        <div ref={ divRef } style={{ background: 'red', height: height }}>Hello</div>
        <button onClick={ () => setHeight(height + 50) }>改变 div 高度</button>
    </>

}

useDebugValue with TypeScript

useDebugValue是用于调试自定义挂钩(自定义挂钩请参考https://reactjs.org/docs/hooks-custom.html)的工具。它允许您在 React Dev Tools 中显示自定义钩子函数的标签。

示例

我之前基于 umi+react+typescript+ant-design 构建了一个简单的中后台通用模板。

涵盖的功能如下:

- 组件
  - 基础表格
  - ECharts 图表
  - 表单
    - 基础表单
    - 分步表单
  - 编辑器

- 控制台
- 错误页面
  - 404

里面对于在 react 中结合Hooks使用 typescript 的各种场景都有很好的实践,大家感兴趣的可以参考一下,https://github.com/FSFED/Umi-hooks/tree/feature_hook,当然不要吝惜你的 star!!!

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Jack-cool,欢迎star!!!

查看原文

dongzhe3917875 收藏了文章 · 4月8日

前端性能优化 24 条建议(2020)

性能优化是把双刃剑,有好的一面也有坏的一面。好的一面就是能提升网站性能,坏的一面就是配置麻烦,或者要遵守的规则太多。并且某些性能优化规则并不适用所有场景,需要谨慎使用,请读者带着批判性的眼光来阅读本文。

本文相关的优化建议的引用资料出处均会在建议后面给出,或者放在文末。

1. 减少 HTTP 请求

一个完整的 HTTP 请求需要经历 DNS 查找,TCP 握手,浏览器发出 HTTP 请求,服务器接收请求,服务器处理请求并发回响应,浏览器接收响应等过程。接下来看一个具体的例子帮助理解 HTTP :

在这里插入图片描述

这是一个 HTTP 请求,请求的文件大小为 28.4KB。

名词解释:

  • Queueing: 在请求队列中的时间。
  • Stalled: 从TCP 连接建立完成,到真正可以传输数据之间的时间差,此时间包括代理协商时间。
  • Proxy negotiation: 与代理服务器连接进行协商所花费的时间。
  • DNS Lookup: 执行DNS查找所花费的时间,页面上的每个不同的域都需要进行DNS查找。
  • Initial Connection / Connecting: 建立连接所花费的时间,包括TCP握手/重试和协商SSL。
  • SSL: 完成SSL握手所花费的时间。
  • Request sent: 发出网络请求所花费的时间,通常为一毫秒的时间。
  • Waiting(TFFB): TFFB 是发出页面请求到接收到应答数据第一个字节的时间总和,它包含了 DNS 解析时间、 TCP 连接时间、发送 HTTP 请求时间和获得响应消息第一个字节的时间。
  • Content Download: 接收响应数据所花费的时间。

从这个例子可以看出,真正下载数据的时间占比为 13.05 / 204.16 = 6.39%,文件越小,这个比例越小,文件越大,比例就越高。这就是为什么要建议将多个小文件合并为一个大文件,从而减少 HTTP 请求次数的原因。

参考资料:

2. 使用 HTTP2

HTTP2 相比 HTTP1.1 有如下几个优点:

解析速度快

服务器解析 HTTP1.1 的请求时,必须不断地读入字节,直到遇到分隔符 CRLF 为止。而解析 HTTP2 的请求就不用这么麻烦,因为 HTTP2 是基于帧的协议,每个帧都有表示帧长度的字段。

多路复用

HTTP1.1 如果要同时发起多个请求,就得建立多个 TCP 连接,因为一个 TCP 连接同时只能处理一个 HTTP1.1 的请求。

在 HTTP2 上,多个请求可以共用一个 TCP 连接,这称为多路复用。同一个请求和响应用一个流来表示,并有唯一的流 ID 来标识。
多个请求和响应在 TCP 连接中可以乱序发送,到达目的地后再通过流 ID 重新组建。

首部压缩

HTTP2 提供了首部压缩功能。

例如有如下两个请求:

:authority: unpkg.zhimg.com
:method: GET
:path: /za-js-sdk@2.16.0/dist/zap.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
:authority: zz.bdstatic.com
:method: GET
:path: /linksubmit/push.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36

从上面两个请求可以看出来,有很多数据都是重复的。如果可以把相同的首部存储起来,仅发送它们之间不同的部分,就可以节省不少的流量,加快请求的时间。

HTTP/2 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送。

下面再来看一个简化的例子,假设客户端按顺序发送如下请求首部:

Header1:foo
Header2:bar
Header3:bat

当客户端发送请求时,它会根据首部值创建一张表:

索引首部名称
62Header1foo
63Header2bar
64Header3bat

如果服务器收到了请求,它会照样创建一张表。
当客户端发送下一个请求的时候,如果首部相同,它可以直接发送这样的首部块:

62 63 64

服务器会查找先前建立的表格,并把这些数字还原成索引对应的完整首部。

优先级

HTTP2 可以对比较紧急的请求设置一个较高的优先级,服务器在收到这样的请求后,可以优先处理。

流量控制

由于一个 TCP 连接流量带宽(根据客户端到服务器的网络带宽而定)是固定的,当有多个请求并发时,一个请求占的流量多,另一个请求占的流量就会少。流量控制可以对不同的流的流量进行精确控制。

服务器推送

HTTP2 新增的一个强大的新功能,就是服务器可以对一个客户端请求发送多个响应。换句话说,除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。

例如当浏览器请求一个网站时,除了返回 HTML 页面外,服务器还可以根据 HTML 页面中的资源的 URL,来提前推送资源。

现在有很多网站已经开始使用 HTTP2 了,例如知乎:

在这里插入图片描述

其中 h2 是指 HTTP2 协议,http/1.1 则是指 HTTP1.1 协议。

参考资料:

3. 使用服务端渲染

客户端渲染: 获取 HTML 文件,根据需要下载 JavaScript 文件,运行文件,生成 DOM,再渲染。

服务端渲染:服务端返回 HTML 文件,客户端只需解析 HTML。

  • 优点:首屏渲染快,SEO 好。
  • 缺点:配置麻烦,增加了服务器的计算压力。

参考资料:

4. 静态资源使用 CDN

内容分发网络(CDN)是一组分布在多个不同地理位置的 Web 服务器。我们都知道,当服务器离用户越远时,延迟越高。CDN 就是为了解决这一问题,在多个位置部署服务器,让用户离服务器更近,从而缩短请求时间。

CDN 原理

当用户访问一个网站时,如果没有 CDN,过程是这样的:

  1. 浏览器要将域名解析为 IP 地址,所以需要向本地 DNS 发出请求。
  2. 本地 DNS 依次向根服务器、顶级域名服务器、权限服务器发出请求,得到网站服务器的 IP 地址。
  3. 本地 DNS 将 IP 地址发回给浏览器,浏览器向网站服务器 IP 地址发出请求并得到资源。

如果用户访问的网站部署了 CDN,过程是这样的:

  1. 浏览器要将域名解析为 IP 地址,所以需要向本地 DNS 发出请求。
  2. 本地 DNS 依次向根服务器、顶级域名服务器、权限服务器发出请求,得到全局负载均衡系统(GSLB)的 IP 地址。
  3. 本地 DNS 再向 GSLB 发出请求,GSLB 的主要功能是根据本地 DNS 的 IP 地址判断用户的位置,筛选出距离用户较近的本地负载均衡系统(SLB),并将该 SLB 的 IP 地址作为结果返回给本地 DNS。
  4. 本地 DNS 将 SLB 的 IP 地址发回给浏览器,浏览器向 SLB 发出请求。
  5. SLB 根据浏览器请求的资源和地址,选出最优的缓存服务器发回给浏览器。
  6. 浏览器再根据 SLB 发回的地址重定向到缓存服务器。
  7. 如果缓存服务器有浏览器需要的资源,就将资源发回给浏览器。如果没有,就向源服务器请求资源,再发给浏览器并缓存在本地。

参考资料:

5. 将 CSS 放在文件头部,JavaScript 文件放在底部

所有放在 head 标签里的 CSS 和 JS 文件都会堵塞渲染。如果这些 CSS 和 JS 需要加载和解析很久的话,那么页面就空白了。所以 JS 文件要放在底部,等 HTML 解析完了再加载 JS 文件。

那为什么 CSS 文件还要放在头部呢?

因为先加载 HTML 再加载 CSS,会让用户第一时间看到的页面是没有样式的、“丑陋”的,为了避免这种情况发生,就要将 CSS 文件放在头部了。

另外,JS 文件也不是不可以放在头部,只要给 script 标签加上 defer 属性就可以了,异步下载,延迟执行。

6. 使用字体图标 iconfont 代替图片图标

字体图标就是将图标制作成一个字体,使用时就跟字体一样,可以设置属性,例如 font-size、color 等等,非常方便。并且字体图标是矢量图,不会失真。还有一个优点是生成的文件特别小。

参考资料:

7. 善用缓存,不重复加载相同的资源

为了避免用户每次访问网站都得请求文件,我们可以通过添加 Expires 或 max-age 来控制这一行为。Expires 设置了一个时间,只要在这个时间之前,浏览器都不会请求文件,而是直接使用缓存。而 max-age 是一个相对时间,建议使用 max-age 代替 Expires 。

不过这样会产生一个问题,当文件更新了怎么办?怎么通知浏览器重新请求文件?

可以通过更新页面中引用的资源链接地址,让浏览器主动放弃缓存,加载新资源。

具体做法是把资源地址 URL 的修改与文件内容关联起来,也就是说,只有文件内容变化,才会导致相应 URL 的变更,从而实现文件级别的精确缓存控制。什么东西与文件内容相关呢?我们会很自然的联想到利用数据摘要要算法对文件求摘要信息,摘要信息与文件内容一一对应,就有了一种可以精确到单个文件粒度的缓存控制依据了。

参考资料:

8. 压缩文件

压缩文件可以减少文件下载时间,让用户体验性更好。

得益于 webpack 和 node 的发展,现在压缩文件已经非常方便了。

在 webpack 可以使用如下插件进行压缩:

  • JavaScript:UglifyPlugin
  • CSS :MiniCssExtractPlugin
  • HTML:HtmlWebpackPlugin

其实,我们还可以做得更好。那就是使用 gzip 压缩。可以通过向 HTTP 请求头中的 Accept-Encoding 头添加 gzip 标识来开启这一功能。当然,服务器也得支持这一功能。

gzip 是目前最流行和最有效的压缩方法。举个例子,我用 Vue 开发的项目构建后生成的 app.js 文件大小为 1.4MB,使用 gzip 压缩后只有 573KB,体积减少了将近 60%。

附上 webpack 和 node 配置 gzip 的使用方法。

下载插件

npm install compression-webpack-plugin --save-dev
npm install compression

webpack 配置

const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [new CompressionPlugin()],
}

node 配置

const compression = require('compression')
// 在其他中间件前使用
app.use(compression())

9. 图片优化

(1). 图片延迟加载

在页面中,先不给图片设置路径,只有当图片出现在浏览器的可视区域时,才去加载真正的图片,这就是延迟加载。对于图片很多的网站来说,一次性加载全部图片,会对用户体验造成很大的影响,所以需要使用图片延迟加载。

首先可以将图片这样设置,在页面不可见时图片不会加载:

<img data-data-original="https://avatars0.githubusercontent.com/u/22117876?s=460&u=7bd8f32788df6988833da6bd155c3cfbebc68006&v=4">

等页面可见时,使用 JS 加载图片:

const img = document.querySelector('img')
img.src = img.dataset.src

这样图片就加载出来了,完整的代码可以看一下参考资料。

参考资料:

(2). 响应式图片

响应式图片的优点是浏览器能够根据屏幕大小自动加载合适的图片。

通过 picture 实现

<picture>
    <source srcset="banner_w1000.jpg" media="(min-width: 801px)">
    <source srcset="banner_w800.jpg" media="(max-width: 800px)">
    <img data-original="banner_w800.jpg" alt="">
</picture>

通过 @media 实现

@media (min-width: 769px) {
    .bg {
        background-image: url(bg1080.jpg);
    }
}
@media (max-width: 768px) {
    .bg {
        background-image: url(bg768.jpg);
    }
}

(3). 调整图片大小

例如,你有一个 1920 * 1080 大小的图片,用缩略图的方式展示给用户,并且当用户鼠标悬停在上面时才展示全图。如果用户从未真正将鼠标悬停在缩略图上,则浪费了下载图片的时间。

所以,我们可以用两张图片来实行优化。一开始,只加载缩略图,当用户悬停在图片上时,才加载大图。还有一种办法,即对大图进行延迟加载,在所有元素都加载完成后手动更改大图的 src 进行下载。

(4). 降低图片质量

例如 JPG 格式的图片,100% 的质量和 90% 质量的通常看不出来区别,尤其是用来当背景图的时候。我经常用 PS 切背景图时, 将图片切成 JPG 格式,并且将它压缩到 60% 的质量,基本上看不出来区别。

压缩方法有两种,一是通过 webpack 插件 image-webpack-loader,二是通过在线网站进行压缩。

以下附上 webpack 插件 image-webpack-loader 的用法。

npm i -D image-webpack-loader

webpack 配置

{
  test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
  use:[
    {
    loader: 'url-loader',
    options: {
      limit: 10000, /* 图片大小小于1000字节限制时会自动转成 base64 码引用*/
      name: utils.assetsPath('img/[name].[hash:7].[ext]')
      }
    },
    /*对图片进行压缩*/
    {
      loader: 'image-webpack-loader',
      options: {
        bypassOnDebug: true,
      }
    }
  ]
}

(5). 尽可能利用 CSS3 效果代替图片

有很多图片使用 CSS 效果(渐变、阴影等)就能画出来,这种情况选择 CSS3 效果更好。因为代码大小通常是图片大小的几分之一甚至几十分之一。

参考资料:

10. 通过 webpack 按需加载代码,提取第三库代码,减少 ES6 转为 ES5 的冗余代码

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

根据文件内容生成文件名,结合 import 动态引入组件实现按需加载

通过配置 output 的 filename 属性可以实现这个需求。filename 属性的值选项中有一个 [contenthash],它将根据文件内容创建出唯一 hash。当文件内容发生变化时,[contenthash] 也会发生变化。

output: {
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].js',
    path: path.resolve(__dirname, '../dist'),
},

提取第三方库

由于引入的第三方库一般都比较稳定,不会经常改变。所以将它们单独提取出来,作为长期缓存是一个更好的选择。
这里需要使用 webpack4 的 splitChunk 插件 cacheGroups 选项。

optimization: {
      runtimeChunk: {
        name: 'manifest' // 将 webpack 的 runtime 代码拆分为一个单独的 chunk。
    },
    splitChunks: {
        cacheGroups: {
            vendor: {
                name: 'chunk-vendors',
                test: /[\\/]node_modules[\\/]/,
                priority: -10,
                chunks: 'initial'
            },
            common: {
                name: 'chunk-common',
                minChunks: 2,
                priority: -20,
                chunks: 'initial',
                reuseExistingChunk: true
            }
        },
    }
},
  • test: 用于控制哪些模块被这个缓存组匹配到。原封不动传递出去的话,它默认会选择所有的模块。可以传递的值类型:RegExp、String和Function;
  • priority:表示抽取权重,数字越大表示优先级越高。因为一个 module 可能会满足多个 cacheGroups 的条件,那么抽取到哪个就由权重最高的说了算;
  • reuseExistingChunk:表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的。
  • minChunks(默认是1):在分割之前,这个代码块最小应该被引用的次数(译注:保证代码块复用性,默认配置的策略是不需要多次引用也可以被分割)
  • chunks (默认是async) :initial、async和all
  • name(打包的chunks的名字):字符串或者函数(函数可以根据条件自定义名字)

减少 ES6 转为 ES5 的冗余代码

Babel 转化后的代码想要实现和原来代码一样的功能需要借助一些帮助函数,比如:

class Person {}

会被转换为:

"use strict";

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

var Person = function Person() {
  _classCallCheck(this, Person);
};

这里 _classCallCheck 就是一个 helper 函数,如果在很多文件里都声明了类,那么就会产生很多个这样的 helper 函数。

这里的 @babel/runtime 包就声明了所有需要用到的帮助函数,而 @babel/plugin-transform-runtime 的作用就是将所有需要 helper 函数的文件,从 @babel/runtime包 引进来:

"use strict";

var _classCallCheck2 = require("@babel/runtime/helpers/classCallCheck");

var _classCallCheck3 = _interopRequireDefault(_classCallCheck2);

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

var Person = function Person() {
  (0, _classCallCheck3.default)(this, Person);
};

这里就没有再编译出 helper 函数 classCallCheck 了,而是直接引用了 @babel/runtime 中的 helpers/classCallCheck

安装

npm i -D @babel/plugin-transform-runtime @babel/runtime

使用
.babelrc 文件中

"plugins": [
        "@babel/plugin-transform-runtime"
]

参考资料:

11. 减少重绘重排

浏览器渲染过程

  1. 解析HTML生成DOM树。
  2. 解析CSS生成CSSOM规则树。
  3. 将DOM树与CSSOM规则树合并在一起生成渲染树。
  4. 遍历渲染树开始布局,计算每个节点的位置大小信息。
  5. 将渲染树每个节点绘制到屏幕。

在这里插入图片描述

重排

当改变 DOM 元素位置或大小时,会导致浏览器重新生成渲染树,这个过程叫重排。

重绘

当重新生成渲染树后,就要将渲染树每个节点绘制到屏幕,这个过程叫重绘。不是所有的动作都会导致重排,例如改变字体颜色,只会导致重绘。记住,重排会导致重绘,重绘不会导致重排 。

重排和重绘这两个操作都是非常昂贵的,因为 JavaScript 引擎线程与 GUI 渲染线程是互斥,它们同时只能一个在工作。

什么操作会导致重排?

  • 添加或删除可见的 DOM 元素
  • 元素位置改变
  • 元素尺寸改变
  • 内容改变
  • 浏览器窗口尺寸改变

如何减少重排重绘?

  • 用 JavaScript 修改样式时,最好不要直接写样式,而是替换 class 来改变样式。
  • 如果要对 DOM 元素执行一系列操作,可以将 DOM 元素脱离文档流,修改完成后,再将它带回文档。推荐使用隐藏元素(display:none)或文档碎片(DocumentFragement),都能很好的实现这个方案。

12. 使用事件委托

事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。所有用到按钮的事件(多数鼠标事件和键盘事件)都适合采用事件委托技术, 使用事件委托可以节省内存。

<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>凤梨</li>
</ul>

// good
document.querySelector('ul').onclick = (event) => {
  const target = event.target
  if (target.nodeName === 'LI') {
    console.log(target.innerHTML)
  }
}

// bad
document.querySelectorAll('li').forEach((e) => {
  e.onclick = function() {
    console.log(this.innerHTML)
  }
}) 

13. 注意程序的局部性

一个编写良好的计算机程序常常具有良好的局部性,它们倾向于引用最近引用过的数据项附近的数据项,或者最近引用过的数据项本身,这种倾向性,被称为局部性原理。有良好局部性的程序比局部性差的程序运行得更快。

局部性通常有两种不同的形式:

  • 时间局部性:在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来被多次引用。
  • 空间局部性 :在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。

时间局部性示例

function sum(arry) {
    let i, sum = 0
    let len = arry.length

    for (i = 0; i < len; i++) {
        sum += arry[i]
    }

    return sum
}

在这个例子中,变量sum在每次循环迭代中被引用一次,因此,对于sum来说,具有良好的时间局部性

空间局部性示例

具有良好空间局部性的程序

// 二维数组 
function sum1(arry, rows, cols) {
    let i, j, sum = 0

    for (i = 0; i < rows; i++) {
        for (j = 0; j < cols; j++) {
            sum += arry[i][j]
        }
    }
    return sum
}

空间局部性差的程序

// 二维数组 
function sum2(arry, rows, cols) {
    let i, j, sum = 0

    for (j = 0; j < cols; j++) {
        for (i = 0; i < rows; i++) {
            sum += arry[i][j]
        }
    }
    return sum
}

看一下上面的两个空间局部性示例,像示例中从每行开始按顺序访问数组每个元素的方式,称为具有步长为1的引用模式。
如果在数组中,每隔k个元素进行访问,就称为步长为k的引用模式。
一般而言,随着步长的增加,空间局部性下降。

这两个例子有什么区别?区别在于第一个示例是按行扫描数组,每扫描完一行再去扫下一行;第二个示例是按列来扫描数组,扫完一行中的一个元素,马上就去扫下一行中的同一列元素。

数组在内存中是按照行顺序来存放的,结果就是逐行扫描数组的示例得到了步长为 1 引用模式,具有良好的空间局部性;而另一个示例步长为 rows,空间局部性极差。

性能测试

运行环境:

  • cpu: i5-7400
  • 浏览器: chrome 70.0.3538.110

对一个长度为9000的二维数组(子数组长度也为9000)进行10次空间局部性测试,时间(毫秒)取平均值,结果如下:

所用示例为上述两个空间局部性示例

步长为 1步长为 9000
1242316

从以上测试结果来看,步长为 1 的数组执行时间比步长为 9000 的数组快了一个数量级。

总结:

  • 重复引用相同变量的程序具有良好的时间局部性
  • 对于具有步长为 k 的引用模式的程序,步长越小,空间局部性越好;而在内存中以大步长跳来跳去的程序空间局部性会很差

参考资料:

14. if-else 对比 switch

当判断条件数量越来越多时,越倾向于使用 switch 而不是 if-else。

if (color == 'blue') {

} else if (color == 'yellow') {

} else if (color == 'white') {

} else if (color == 'black') {

} else if (color == 'green') {

} else if (color == 'orange') {

} else if (color == 'pink') {

}

switch (color) {
    case 'blue':

        break
    case 'yellow':

        break
    case 'white':

        break
    case 'black':

        break
    case 'green':

        break
    case 'orange':

        break
    case 'pink':

        break
}

像以上这种情况,使用 switch 是最好的。假设 color 的值为 pink,则 if-else 语句要进行 7 次判断,switch 只需要进行一次判断。
从可读性来说,switch 语句也更好。从使用时机来说,当条件值大于两个的时候,使用 switch 更好。

不过,switch 只能用于 case 值为常量的分支结构,而 if-else 更加灵活。

15. 查找表

当条件语句特别多时,使用 switch 和 if-else 不是最佳的选择,这时不妨试一下查找表。查找表可以使用数组和对象来构建。

switch (index) {
    case '0':
        return result0
    case '1':
        return result1
    case '2':
        return result2
    case '3':
        return result3
    case '4':
        return result4
    case '5':
        return result5
    case '6':
        return result6
    case '7':
        return result7
    case '8':
        return result8
    case '9':
        return result9
    case '10':
        return result10
    case '11':
        return result11
}

可以将这个 switch 语句转换为查找表

const results = [result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10,result11]

return results[index]

如果条件语句不是数值而是字符串,可以用对象来建立查找表

const map = {
  red: result0,
  green: result1,
}

return map[color]

16. 避免页面卡顿

60fps 与设备刷新率

目前大多数设备的屏幕刷新率为 60 次/秒。因此,如果在页面中有一个动画或渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。
其中每个帧的预算时间仅比 16 毫秒多一点 (1 秒/ 60 = 16.66 毫秒)。但实际上,浏览器有整理工作要做,因此您的所有工作需要在 10 毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。 此现象通常称为卡顿,会对用户体验产生负面影响。

在这里插入图片描述

假如你用 JavaScript 修改了 DOM,并触发样式修改,经历重排重绘最后画到屏幕上。如果这其中任意一项的执行时间过长,都会导致渲染这一帧的时间过长,平均帧率就会下降。假设这一帧花了 50 ms,那么此时的帧率为 1s / 50ms = 20fps,页面看起来就像卡顿了一样。

对于一些长时间运行的 JavaScript,我们可以使用定时器进行切分,延迟执行。

for (let i = 0, len = arry.length; i < len; i++) {
    process(arry[i])
}

假设上面的循环结构由于 process() 复杂度过高或数组元素太多,甚至两者都有,可以尝试一下切分。

const todo = arry.concat()
setTimeout(() => {
    process(todo.shift())
    if (todo.length) {
        setTimeout(arguments.callee, 25)
    } else {
        callback(arry)
    }
}, 25)

如果有兴趣了解更多,可以查看一下高性能JavaScript第 6 章和高效前端:Web高效编程与优化实践第 3 章。

参考资料:

17. 使用 requestAnimationFrame 来实现视觉变化

从第 16 点我们可以知道,大多数设备屏幕刷新率为 60 次/秒,也就是说每一帧的平均时间为 16.66 毫秒。在使用 JavaScript 实现动画效果的时候,最好的情况就是每次代码都是在帧的开头开始执行。而保证 JavaScript 在帧开始时运行的唯一方式是使用 requestAnimationFrame

/**
 * If run as a requestAnimationFrame callback, this
 * will be run at the start of the frame.
 */
function updateScreen(time) {
  // Make visual updates here.
}

requestAnimationFrame(updateScreen);

如果采取 setTimeoutsetInterval 来实现动画的话,回调函数将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿。

在这里插入图片描述

参考资料:

18. 使用 Web Workers

Web Worker 使用其他工作线程从而独立于主线程之外,它可以执行任务而不干扰用户界面。一个 worker 可以将消息发送到创建它的 JavaScript 代码, 通过将消息发送到该代码指定的事件处理程序(反之亦然)。

Web Worker 适用于那些处理纯数据,或者与浏览器 UI 无关的长时间运行脚本。

创建一个新的 worker 很简单,指定一个脚本的 URI 来执行 worker 线程(main.js):

var myWorker = new Worker('worker.js');
// 你可以通过postMessage() 方法和onmessage事件向worker发送消息。
first.onchange = function() {
  myWorker.postMessage([first.value,second.value]);
  console.log('Message posted to worker');
}

second.onchange = function() {
  myWorker.postMessage([first.value,second.value]);
  console.log('Message posted to worker');
}

在 worker 中接收到消息后,我们可以写一个事件处理函数代码作为响应(worker.js):

onmessage = function(e) {
  console.log('Message received from main script');
  var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
  console.log('Posting message back to main script');
  postMessage(workerResult);
}

onmessage处理函数在接收到消息后马上执行,代码中消息本身作为事件的data属性进行使用。这里我们简单的对这2个数字作乘法处理并再次使用postMessage()方法,将结果回传给主线程。

回到主线程,我们再次使用onmessage以响应worker回传的消息:

myWorker.onmessage = function(e) {
  result.textContent = e.data;
  console.log('Message received from worker');
}

在这里我们获取消息事件的data,并且将它设置为result的textContent,所以用户可以直接看到运算的结果。

不过在worker内,不能直接操作DOM节点,也不能使用window对象的默认方法和属性。然而你可以使用大量window对象之下的东西,包括WebSockets,IndexedDB以及FireFox OS专用的Data Store API等数据存储机制。

参考资料:

19. 使用位操作

JavaScript 中的数字都使用 IEEE-754 标准以 64 位格式存储。但是在位操作中,数字被转换为有符号的 32 位格式。即使需要转换,位操作也比其他数学运算和布尔操作快得多。

取模

由于偶数的最低位为 0,奇数为 1,所以取模运算可以用位操作来代替。

if (value % 2) {
    // 奇数
} else {
    // 偶数 
}
// 位操作
if (value & 1) {
    // 奇数
} else {
    // 偶数
}
取反
~~10.12 // 10
~~10 // 10
~~'1.5' // 1
~~undefined // 0
~~null // 0
位掩码
const a = 1
const b = 2
const c = 4
const options = a | b | c

通过定义这些选项,可以用按位与操作来判断 a/b/c 是否在 options 中。

// 选项 b 是否在选项中
if (b & options) {
    ...
}

20. 不要覆盖原生方法

无论你的 JavaScript 代码如何优化,都比不上原生方法。因为原生方法是用低级语言写的(C/C++),并且被编译成机器码,成为浏览器的一部分。当原生方法可用时,尽量使用它们,特别是数学运算和 DOM 操作。

21. 降低 CSS 选择器的复杂性

(1). 浏览器读取选择器,遵循的原则是从选择器的右边到左边读取。

看个示例

#block .text p {
    color: red;
}
  1. 查找所有 P 元素。
  2. 查找结果 1 中的元素是否有类名为 text 的父元素
  3. 查找结果 2 中的元素是否有 id 为 block 的父元素

(2). CSS 选择器优先级

内联 > ID选择器 > 类选择器 > 标签选择器

根据以上两个信息可以得出结论。

  1. 选择器越短越好。
  2. 尽量使用高优先级的选择器,例如 ID 和类选择器。
  3. 避免使用通配符 *。

最后要说一句,据我查找的资料所得,CSS 选择器没有优化的必要,因为最慢和慢快的选择器性能差别非常小。

参考资料:

22. 使用 flexbox 而不是较早的布局模型

在早期的 CSS 布局方式中我们能对元素实行绝对定位、相对定位或浮动定位。而现在,我们有了新的布局方式 flexbox,它比起早期的布局方式来说有个优势,那就是性能比较好。

下面的截图显示了在 1300 个框上使用浮动的布局开销:

在这里插入图片描述

然后我们用 flexbox 来重现这个例子:

在这里插入图片描述

现在,对于相同数量的元素和相同的视觉外观,布局的时间要少得多(本例中为分别 3.5 毫秒和 14 毫秒)。

不过 flexbox 兼容性还是有点问题,不是所有浏览器都支持它,所以要谨慎使用。

各浏览器兼容性:

  • Chrome 29+
  • Firefox 28+
  • Internet Explorer 11
  • Opera 17+
  • Safari 6.1+ (prefixed with -webkit-)
  • Android 4.4+
  • iOS 7.1+ (prefixed with -webkit-)

参考资料:

23. 使用 transform 和 opacity 属性更改来实现动画

在 CSS 中,transforms 和 opacity 这两个属性更改不会触发重排与重绘,它们是可以由合成器(composite)单独处理的属性。

在这里插入图片描述

参考资料:

24. 合理使用规则,避免过度优化

性能优化主要分为两类:

  1. 加载时优化
  2. 运行时优化

上述 23 条建议中,属于加载时优化的是前面 10 条建议,属于运行时优化的是后面 13 条建议。通常来说,没有必要 23 条性能优化规则都用上,根据网站用户群体来做针对性的调整是最好的,节省精力,节省时间。

在解决问题之前,得先找出问题,否则无从下手。所以在做性能优化之前,最好先调查一下网站的加载性能和运行性能。

检查加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    new Date() - performance.timing.navigationStart
</script>

首屏时间比较复杂,得考虑有图片和没有图片的情况。

如果没有图片,则在 window.onload 事件里执行 new Date() - performance.timing.navigationStart 即可获取首屏时间。

如果有图片,则要在最后一个在首屏渲染的图片的 onload 事件里执行 new Date() - performance.timing.navigationStart 获取首屏时间,实施起来比较复杂,在这里限于篇幅就不说了。

检查运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表 FPS 很好。performance 的具体使用方法请用搜索引擎搜索一下,毕竟篇幅有限。

通过检查加载和运行性能,相信你对网站性能已经有了大概了解。所以这时候要做的事情,就是使用上述 23 条建议尽情地去优化你的网站,加油!

参考资料:

其他参考资料

更多文章,欢迎关注

查看原文

dongzhe3917875 关注了标签 · 3月22日

typescript

TypeScript 是微软开发的 JavaScript 的超集,TypeScript兼容JavaScript,可以载入JavaScript代码然后运行。TypeScript与JavaScript相比进步的地方。包括:加入注释,让编译器理解所支持的对象和函数,编译器会移除注释,不会增加开销;增加一个完整的类结构,使之更新是传统的面向对象语言。

关注 1895

dongzhe3917875 收藏了文章 · 2月2日

使用递归遍历并转换树形数据(以 TypeScript 为例)

一个朋友问我应该怎么从一个树的 JSON 数组生成 HTML,使用 <ul><li> 来构建页面元素。于是我简单的画了个树型结构图

clipboard.png

然后写了对应的模拟数据(JavaScript 对象)

const data = {
    name: "A",
    nodes: [
        { name: "B", nodes: [{ name: "F" }] },
        { name: "C" },
        {
            name: "D",
            nodes: [
                { name: "G" },
                { name: "H" },
                { name: "I", nodes: [{ name: "J" }, { name: "K" }] }
            ]
        },
        { name: "E" }
    ]
};

最后写了一个递归,生成了 HTML 的树型结构。原本是用 JavaScript ES6 写的,为了表明数据结构,这里改用 TypeScript 来写:

interface INode {
    name: string;
    nodes?: INode[];
}

function makeTree(roots: INode[]): JQuery<HTMLElement> {
    function makeNode(node: INode): JQuery<HTMLElement> {
        const $div = $("<div>").text(node.name || "");
        const $li = $("<li>").append($div);
        if (node.nodes && node.nodes.length) {
            $li.append(makeNodeList(node.nodes));
        }
        return $li;
    }

    function makeNodeList(nodes: INode[]): JQuery<HTMLElement> {
        return nodes
            .map(child => makeNode(child))
            .reduce(($ul, $li) => {
                return $ul.append($li);
            }, $("<ul>"));
    }

    return makeNodeList(roots);
}

效果还是蛮不错的

clipboard.png

看看源码(转译成 JS 之后的):http://jsfiddle.net/y7bw4yj2/

然后朋友说没看明白,好吧,那我从头讲起

遍历方法

树形数据的遍历有两种方法,大家都知道:广度遍历和深度遍历。一般情况下,广度遍历是采用队列来实现,而深度遍历刚更适合使用递归来实现。

clipboard.png

广度遍历

从图上大致可以理解广度遍历的过程:

  1. 准备一个空队列;
  2. 将根(单根或多根均可)节点放到队列中;
  3. 从队列中取出一个节点
  4. 处理(比如打印)这个节点
  5. 检查节点的子节点,如果有,全部依次添加到队列中
  6. 回到第 3 步开始处理,直到队列为空(处理完成)
function travelWidely(roots: INode[]) {
    const queue: INode[] = [...roots];
    while (queue.length) {
        const node = queue.shift()!;
        // 打印节点名称及其子节点数
        console.log(`${node.name} ${node.nodes && node.nodes.length || ""}`);
        if (node.nodes && node.nodes.length) {
            queue.push(...node.nodes);
        }
    }
}

// 开始遍历
travelWidely([data]);

const node = queue.shift()!,这后面的 ! 后缀表示声明其结果不为 undefinednull。这是一个 TypeScript 语法。由于 .shift() 在数组中没有元素时会返回 undefined,所以其返回类型被声明为 INode | undefined,由于从逻辑可以保证 .shift() 一定会返回一个节点对象,所以这里用 ! 后缀忽略类型中的 undefined 部分,使 node 的类型被推导为 INode

代码里稍难理解一点的是要注意 queue 的内容和长度随时在变化。如果想使用 for 代替 while 循环,节点序号会因 .shift() 而不断变化,所以 i < queue.length 这样的判断是错误的。

深度遍历

深度遍历是一个递归过程,递归一直是编程的难点

递归是一个循环往复的处理过程,它有两个点需要注意:

  • 递归调用点,递归调用自己(或另一个可能会调用自己的函数)
  • 递归结束点,退出当前函数

以树节点为例,我们期望处理过程是处理(打印)一个树结点,即 printNode(node: INode)。那么它的

  • 递归调用点:如果该节点有子节点,依次对子节点调用 printNode(children[i])
  • 递归结束点:处理完所有子节点(子节点数量是有限的,所以一定会结束)

用一段伪代码描述这一过程

function printNode(node: INode) {
    // 处理该节点
    console.log(node.name);
    
    // 递归调用点:循环对子节点调用 printNode
    node.nodes!.forEach(child => printNode(child));
    
    // 递归结束点:循环完成,return
}

上面两句代码就完成了递归过程,但实际上情况还要复杂些,因为要处理入口和容错。

// 注意参数支持传入单根或多根,
// 如果像 travelWidely 那样只支持多根(单根是特例)也是可以的
function travelDeeply(roots: INode | INode[]) {
    function printNode(node: INode) {
        console.log(`${node.name} ${node.nodes && node.nodes.length || ""}`);
        if (node.nodes && node.nodes.length) {
            // 依次对子节点递归调用 printNode
            node.nodes.forEach(child => printNode(child));
        }
    }

    // 这里 printNode 和 node => printNode(node) 等价
    (Array.isArray(roots) ? roots : [roots]).forEach(printNode);
}

// 开始遍历
travelDeeply(data);

关于递归,我正好在慕课网上讲生成数据解决方案的时候讲到了,有兴趣可以看看。

遍历还没讲完

上面两种遍历都讲到了,但是还没讲完——因为两种遍历都是以打印为例,而我们的目的是要生成 DOM 树。生成 DOM 树与纯打印信息的不同之处在于,我们不仅要使用节点信息,还要从节点信息生成 DOM 返回出来。

深度遍历生成节点

这次先讲深度遍历,因为递归更容易实现。递归本身具有层次信息,每进入一个递归调用点,就会深入一层,每离开一个递归结束点,就会减少一层。所以这个算法本身能够保留结构信息,相应代码也会更容易实现。而且在本文一开始,就已经实现出来了。

需要注意的一点是那段代码用了两个函数来完成递归过程:

  • makeNode 处理单个节点,它调用 makeNodeList 处理子节点列表
  • makeNodeList 遍历节点列表,分别对其调用 makeNode 来进行处理

makeNodemakeNodeList 的相互调用形成了递归,上述两条都是递归调用点,而递归结束点同样也有两条:

  • makeNode 处理的节点没有子节点时,不会调用 makeNodeList
  • makeNodeList 中的循环结束时,不会再调用 makeNode

广度遍历生成节点

广度遍历的过程是把所有节点扁平化到一个队列中了,这个过程是不可逆 的,换句话说,我们在处理过程中丢掉了树形结构信息。然后我们要生成的 DOM 树,是需要结构信息的——因此,需要将结构信息附加在每个节点上。这里我们把生成的 DOM 和数据节点绑定起来,由 DOM 保存结构信息。为此,需要修改一下节点类型

interface INode {
    name: string;
    nodes?: INode[];
    dom: JQuery;    // 附加生成的 DOM
}
function makeTreeWidely(roots: INode[]): JQuery {
    // 从一组节点生成 <ul>,为每个节点生成并附加 <li>,
    // 同时将 <li> 到到 <ul> 中保存结构信息
    function makeUl(nodes: INode[]) {
        return nodes
            .map(node => {
                const $li = $("<li>")
                    .append($("<div>").text(node.name || ""));
                node.dom = $li;
                return $li;
            })
            .reduce(($ul, $li) => $ul.append($li), $("<ul>"));
    }

    const $rootUl = makeUl(roots);

    const queue: INode[] = [...roots];
    while (queue.length) {
        const node = queue.shift()!;

        if (node.nodes && node.nodes.length) {
            const $ul = makeUl(node.nodes);
            node.dom.append($ul);
            queue.push(...node.nodes);
        }
    }
    return $rootUl;
}

虽然这里和上面讲递归遍历 printNode 的时候一样定义了局部函数表达式 makeUl,但这里没有递归,因为 makeUl 内部没有调用自身,或者某个会调用 makeUl 的函数。

但问题还是再深入一点,因为上面的代码改变了原数据。而一般情况下,我们应该尽量避免这样的副作用

没有副作用的广度遍历生成节点

// 声明一个新结构,它把 INode 和 DOM 组合在一起。
// 这个结构将代替 INode 作为队列的元素类型
interface IDomNode {
    node: INode;
    dom: JQuery;
}

function makeTreeWidely(roots: INode[]): JQuery {
    // convert 将节点数组转换为 IDomNode 数组,
    // 同时还干了原来 makeUl 干的事情,返回一个 $ul
    function convert(nodes: INode[]) {
        const domNodes = nodes
            .map(node => {
                const $li = $("<li>")
                    .append($("<div>").text(node.name || ""));
                return {
                    node,
                    dom: $li
                };
            });

        const $ul = domNodes
            .reduce(($ul, dn) => $ul.append(dn.dom), $("<ul>"));

        // 将两个数组组成一个元组(对象)返回
        return {
            domNodes,
            $ul
        };
    }

    // 解析元组,声明变量 queue 和 $rootUl,
    // 并分别将 domNodes 和 $ul 的值赋值给 queue 和 $rootUl 两个变量
    const { domNodes: queue, $ul: $rootUl } = convert(roots);

    while (queue.length) {
        const { node, dom } = queue.shift()!;

        if (node.nodes && node.nodes.length) {
            const { domNodes, $ul } = convert(node.nodes);
            dom.append($ul);
            queue.push(...domNodes);
        }
    }
    return $rootUl;
}

看疗效:http://jsfiddle.net/y7bw4yj2/1/

查看原文

dongzhe3917875 赞了文章 · 1月20日

TypeScript 真的值得吗?

作者:Paul Cowan

翻译:疯狂的技术宅

原文:https://blog.logrocket.com/is...

未经允许严禁转载

在开始之前,希望大家知道,我是 TypeScript 爱好者。它是我在前端 React 项目和基于后端 Node 工作时的主要编程语言。但我确实有一些疑惑,所以想在本文中进行讨论。迄今为止,我已经用 TypeScript 写了至少三年的代码,所以 TypeScript 做得的确不错,而且满足了我的需求。

TypeScript 克服了一些很难解决的问题,并成为前端编程领域的主流。 TypeScript 在这篇列出了最受欢迎的编程语言的文章中排名第七位。

无论是否使用 TypeScript,任何规模的开发团队都应该遵循以下惯例:

  • 编写良好的单元测试——应在合理范围内涵盖尽可能多的生产代码
  • 结对编程——额外的审视可以捕捉到的错误远远超过语法错误
  • 良好的同行评审流程——正确的同行评审可以检查出许多机器无法捕获的错误
  • 使用 linter,例如 eslint

TypeScript 可以在这些基础之上增加额外的安全性,但我认为这在编程语言需求列表中应该排在后面。

TypeScript 不是健全的类型系统

我认为这可能是 TypeScript 当前版本的主要问题,但是首先让我定义 健全非健全 的类型系统。

健全性

健全的类型系统是能够确保你的程序不会进入无效状态的系统。例如,如果表达式中的静态类型为 string,则在运行时,要保证在评估它时仅获得 string

在健全的类型系统中,绝对不会在编译时或运行时产生表达式与预期类型不匹配的情况。

当然 TypeScript 有一定程度的健全性,并捕获以下类型错误:

// 'string' 类型不可分配给 'number' 类型
const increment = (i: number): number => { return i + "1"; }

// Argument of type '"98765432"' is not assignable to parameter of type .
// 无法将参数类型 '"98765432"' 分配给参数类型'number'。
const countdown: number = increment("98765432");

不健全

100% 的健全性不是 Typescript 的目标,这是在 non-goals of TypeScript 列表中第 3 条中明确指出的事实:

...适用健全或“证明正确的”类型的系统。相反,要在正确性和生产率之间取得平衡。

这意味着不能保证变量在运行时具有定义的类型。我可以用下面的例子来说明这一点:

interface A {
    x: number;
}

let a: A = {x: 3}
let b: {x: number | string} = a; 
b.x = "unsound";
let x: number = a.x; // 不健全的

a.x.toFixed(0); // 什么鬼?

上面的代码是 不健全 的,因为从接口 A 中能够知道 a.x 是一个数字。不幸的是,经过一系列重新分配后,它最终以字符串形式出现,并且以下代码能够编译通过,但是会在运行时出错。

不幸的是,这里显示的表达式可以正确编译:

a.x.toFixed(0);

我认为这可能是 TypeScript 最大的问题,因为健全性不是目标。我仍然会遇到许多运行时错误,tsc 编译器不会标记这些错误。通过这种方法,TypeScript 在健全和不健全的阵营中脚踏两只船。这种半途而废的现象是通过 any 类型强制执行的,我将在后面提到。

我仍然需要编写很多的测试,这让我感到沮丧。当我第一次开始使用 TypeScript 时错误地得出结论:可以不必编写这么多单元测试了。

TypeScript 挑战了现状,并声称降低使用类型的认知开销比类型健全性更重要。

我能够理解为什么 TypesScript 会走这条路,并且有一个论点指出,如果健全类型系统能够得到 100% 的保证,那么对 TypeScript 的使用率讲不会那么高。这种观点随着 dart 语言的逐渐流行( Flutter 现已被广泛使用)被反驳了。健全性是 dart 语言的目标,这里是相关的讨论(https://dart.dev/guides/langu...)。

不健全以及 TypeScript 暴露在严格类型之外的各种转义符使它的有效性大大降低,不过这总比没有强一些。我的愿望是,随着 TypeScript 的流行,能够有更多的编译器选项可供使用,从而使高级用户可以得到 100% 的可靠性。

TypeScript 不保证运行时的类型检查

运行时类型检查不是 TypeScript 的目标,因此这种愿望可能永远不会实现。例如在处理从 API 调用返回的 JSON 时,运行时类型检查将是有好处的。如果可以在类型级别上进行控制,则不需要那么多的错误种类和单元测试。

正是因为无法在运行时保证所有的事情,所以可能会发生:

const getFullName = async (): string => {
  const person: AxiosResponse = await api();
  
  //response.name.fullName 可能会在运行时返回 undefined
  return response.name.fullName
}

尽管有一些很棒的支持库,例如 io-ts,但这可能意味着你必须复制自己的model。

可怕的 any 类型和严格性选项

any 类型就是这样,编译器允许任何操作或赋值。

TypeScript 在一些小细节上往往很好用,但是人们倾向于在 any 类型上花费很多时间。我最近在一个 Angular 项目中工作,看到很多这样的代码:

export class Person {
 public _id: any;
 public name: any;
 public icon: any;

TypeScript 让你忘记类型系统。

你可以用 any 强制转换任何一种类型:

("oh my goodness" as any).ToFixed(1); // 还记得我说的健全性吗?

strict 编译器选项启用了以下编译器设置,这些设置会使事情听起来更加合理:

  • --strictNullChecks
  • --noImplicitAny
  • --noImplicitThis
  • --alwaysStrict

还有 eslint 规则 @typescript-eslint/no-explicit-any

any 的泛滥会破坏你类型的健全性。

结论

必须重申,我是 TypeScript 爱好者,而且一直在日常工作中使用它,但是我确实认为它出现的时间还很短,而且类型还并不完全合理。 Airbnb 声称 TypeScript 可以阻止 38% 的错误。我非常怀疑这个数字的准确性。 TypeScript 不会对现有的做法有良好的提高。我仍然必须编写尽可能多的测试。你可能会不同意,不过我一直在编写更多的代码,并且不得不去编写类型测试,同时仍然会遇到意外的运行时错误。

TypeScript 提供了基本的类型检查,但健全性和运行时类型检查不是它的目标,这使 TypeScript 在美好的世界和我们所处的现状中采取折衷。

TypeScript 的亮点在于有良好的 IDE 支持,例如 vscode,如果我们输入了错误的内容,将会获得很好的视觉反馈。

image.png
vscode中的TypeScript错误

通过 TypeScript 还可以增强重构的功能,并且在对修改后的代码进行编译时,可以立即识别出代码的改变(例如方法签名的更改)。

TypeScript 启用了良好的类型检查,并且绝对要比没有类型检查或仅使用普通的 eslint 更好,但是我认为它还可以做更多的事情。对于那些想要更多的人来说,还能够提供足够多的编译器选项。


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

欢迎继续阅读本专栏其它高赞文章:


查看原文

赞 12 收藏 9 评论 3

认证与成就

  • 获得 198 次点赞
  • 获得 12 枚徽章 获得 1 枚金徽章, 获得 2 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • 奇云

    360奇云平台,从零到一的前端开发。

注册于 2015-07-30
个人主页被 1.6k 人浏览