使用TypeScript Compiler APIs

一个进行中或完成的React项目如果要进行国际化,那么第一步需要从源码中提取中文词条,这往往是一个体力活,并且有无法找到所有的中文词条的风险。我们可以开发工具来代替人工提取,简单点可以使用基于字符串和正则表达式查找就可以完成,这种方式有一个很大问题:中文词条的提取效率取决于你正则表达式有多强大,并且如果后续有词条替换的需求,实现起来相对复杂。下面要介绍另一种方式,从String -> AST -> String的方式,这里我使用了TS Compiler API

认识Compiler APIs

TS早在2.x版本就提供了一系列的API来更好的操作TypeScript AST,利用这些API,可以很方便的编写插件来影响TS编译过程,当然这些API也可以单独使用。关于AST这里并不做过多介绍,推荐一篇文章:深入Babel,这一篇就够了,以Babel举例,详细描述了Babel编译(转译)的过程,以及如何编写Babel插件,TS的工作流程和Babel某种程度上是相似的。

下面介绍几个常用的API

createSourceFile

1
function createSourceFile(fileName: string, sourceText: string, languageVersion: ScriptTarget, setParentNodes?: boolean, scriptKind: ScriptKind): SourceFile;

该方法接受源代码(文件名/字符串)并返回SourceFile,那SourceFile是否就是AST呢?再看SourceFile的定义:

1
2
3
4
5
6
7
8
interface SourceFile extends Declaration {
kind: SyntaxKind.SourceFile;
statements: NodeArray<Statement>;
endOfFileToken: Token<SyntaxKind.EndOfFileToken>;
fileName: string;
text: string;
...
}

通过查看SourceFile的定义,我们可以把SourceFile当做是TypeScriptAST,其中statements属性是源代码语句的数组,Statement也就是AST中的节点(Node)。

好了,如果我们有一份使用TSReact源代码,对于每个.tsx文件而言,使用下面的方式就可以得到AST了。

1
const ast = ts.createSourceFile('', codeString, ts.ScriptTarget.ES2015, true, ts.ScriptKind.TSX)

Printer

使用createSourceFile可以将源代码转成SourceFile,那么Printer就是把SourceFile/Node转成字符串的APIs,其常用的几个API如下:

1
2
3
4
5
function createPrinter(printerOptions?: PrinterOptions, handlers?: PrintHandlers): Printer;
interface Printer {
printFile(sourceFile: SourceFile): string;
printNode(hint: EmitHint, node: Node, sourceFile: SourceFile): string;
}

  • createPrinter返回一个Printer实例,该实例可使用既定的printerOptionsNodeSourceFile进行打印(生成字符串形式)
  • printFile依据SourceFile打印字符串源码,不进行任何转换
  • printNode打印节点
1
2
3
4
5
6
7
8
9
const sourceFile: ts.SourceFile = 
ts.createSourceFile('test.ts', '', ts.ScriptTarget.ES2015, true, ts.ScriptKind.TS);
// printFile
const printer = ts.createPrinter()
console.log(printer.printFile(sourceFile)) // test.ts源码
// printNode
const node = ts.createAdd(ts.createLiteral(1), ts.createLiteral(2))
console.log(printer.printNode(ts.EmitHint.Expression, node, sourceFile)); // 1 + 2
// note: `printNode`第三个参数其实和打印节点无直接关系

transform

1
function transform<T extends Node>(source: T | T[], transformers: TransformerFactory<T>[], compilerOptions?: CompilerOptions): TransformationResult<T>;

生成AST后就要开始处理了,ts也提供了一系列的API用于遍历ASTforEachChildvisitEachChild都可以遍历AST,初次之外visitEachChild还可以修改节点,并返回修改后节点。

1
2
function visitEachChild<T extends Node>(node: T, visitor: Visitor, context: TransformationContext): T;
function forEachChild<T>(node: Node, cbNode: (node: Node) => T | undefined, cbNodes?: (nodes: NodeArray<Node>) => T | undefined): T | undefined;

transform方法就和其字面意思一样,使用该方法可以转换AST。它接收多个transformer,最简单的transformer可以是下面这样:

1
2
3
4
5
6
7
const transformer = <T extends ts.Node>(context: ts.TransformationContext) => (rootNode: T) => {
function visit(node: T) {
console.log(node.kind)
return ts.visitEachChild(node, visit, context)
}
return ts.visitNode(rootNode, visit)
}

上面的transformer访问了每个节点,并且打印出当前访问节点的kind

下面再写一个比较实际的transformer,找到所有的中文字符串节点,并使用变量来替换该节点。

1
2
3
4
5
6
7
8
9
const transformer = <T extends ts.Node>(context: ts.TransformationContext) => (rootNode: T) => {
function visit(node: T) {
if (node.kind === ts.SyntaxKind.StringLiteral && node.text.match(/[^\x00-\xff]/g)) {
return ts.createIdentifier('placeholder')
}
return ts.visitEachChild(node, visit, context)
}
return ts.visitNode(rootNode, visit)
}

使用上面的transformervar name = '张三'将会被转换成var name = placeholder,文件中所有的StringLiteral节点中只要包含中文都会被转成指定的Identifier

TS还提供了create*update*多个API用于创建和更新节点,当对compiler API更加了解后,我们就可以做更多的事,例如var total = 1 + 2 变成var t = 3类似的代码压缩,自定义lint规则等等。

Compiler APIs的应用

基于Compiler APIs实现了一款React国际化工具 ext-intl,实现了React项目词条提取、词条key生成、代码原处替换等功能。该工具目前是可用的,一定程度上可以提升React项目国际化效率。

使用方式:

1
$ yarn add --dev ext-intl

完。

分享到 评论

Battery Status API 以及useBattery

Battery Status API提供了系统层级的电池信息(电量/充电信息等),并且在这些状态改变的时候提供了一系列的eventListener

有了这些信息,我们可以对应用进行优化。例如:

  • 用户使用电池供电,想要达到好的续航效果,我们可以降低对资源的使用。
  • 用户电量低,我们可以先对用户操作和数据进行缓存,避免数据丢失。
  • 持续收集用户数据,进行用户群体分析。
  • ……

兼容性 Battery Status API的支持度有限,目前只有ChromeOpera以及Android webview支持度是比较好的,并且官方并不推荐使用该功能,未来或被移除。

getBattery返回一个Promise对象,resolve后返回一个battery对象,该对象包含了{ charging, chargingTime, dischargingTime, level }分别表示是否在充电,充电时长,剩余可用时间,电池电量。例如,{ "charging": true, "level": 1, "chargingTime": 0, "dischargingTime": null }

除此之外,battery对象还包含了4个eventlistener(chargingchange/chargingtimechange/dischargingtimechange/levelchange),用于监听4个属性的改变。

下面来写一个例子,获取某一时刻的系统电量信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function getBattery() {
const nav = navigator
if (!nav || typeof nav.getBattery !== 'function') {
return {}
}
const battery = await navigator.getBattery()
console.log(battery.level)
return battery
}

getBattery()
.then(battery => {
// do something
})

useBattery

如果我们要在React中使用Battery Status API,我们仍然可以向上面一样,也可以配合上React hooks来实现一个useBattery hook。

在开始写这个钩子之前,我们先理一下,由于获取电量信息是一个异步的过程,所以这个钩子除了返回上面提到的4个电量信息属性以外,还需要额外的一个属性用于记录数据是否获取完毕。

判断浏览器兼容性只需要判断navigator对象是否包含getBattery函数即可

1
const isSupported = navigator && typeof navigator.getBattery === 'function'

获取某一时刻电量信息的hook如下:

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 { useState, useEffect } from 'react'

const isSupported = navigator && typeof navigator.getBattery === 'function'

function useBattery() {
if(!isSupported) {
return {}
}
const [state, setState] = useState({ fetching: true })
useEffect(() => {
navigator.getBattery()
.then(battery => {
const newState = {
fetching: false,
charging: battery.charging,
level: battery.level,
dischargingTime: battery.dischargingTime,
chargingTime: battery.chargingTime,
}
setState(newState)
})
}, [])
return state
}

在某些情况下,我们可能并不仅仅需要某一时刻的电量信息,显然这一版本的useBattery并不能满足需要。我们需要监听eventListeners,并且在改变后变更状态。

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
import { useState, useEffect } from 'react'

const isSupported = navigator && typeof navigator.getBattery === 'function'

import { isEqual } from 'lodash'

let bat;

function useBattery() {
if(!isSupported) {
return {}
}
const [state, setState] = useState({ fetching: true })
useEffect(() => {
function dealChange() {
const newState = {
fetching: false,
charging: battery.charging,
level: battery.level,
dischargingTime: battery.dischargingTime,
chargingTime: battery.chargingTime,
}
if (!isEqual(state, newState)) {
setState(newState)
}
}
navigator.getBattery()
.then(battery => {
bat = battery
dealChange()
bat.addEventListener('chargingchange', dealChange)
bat.addEventListener('chargingtimechange', dealChange)
bat.addEventListener('dischargingtimechange', dealChange)
bat.addEventListener('levelchange', dealChange)
return () => {
bat.removeEventListener('chargingchange', dealChange)
bat.removeEventListener('chargingtimechange', dealChange)
bat.removeEventListener('dischargingtimechange', dealChange)
bat.removeEventListener('levelchange', dealChange)
}
})
}, [])
return state
}

上面版本的useBattery已经比较完善了,针对每个属性都添加了eventHandler,当属性改变时获取新的state,并通过比较决定是否应用更改。

useEffect第一个参数如果返回一个函数,那么将在unmount的时候执行,所以,上面的代码进行了removeEventListener

尾巴

最近两周本该是上班的时间,由于疫情,我不得不在家待岗。起初的一周,睡睡懒觉, 看会儿NBA,再玩玩游戏,一天就浑浑噩噩的过了。可当游戏也玩得无聊了,懒觉也睡够了,我才发现我是真的没什么事做了,这种日子过得真的很难受!

今天给好久没打开过的Mac充电,翻翻上学时的记忆,无论是文档,代码,邮件…思绪回到六七年前,高中时代的自己,就是因为我对智能手机的狂热追求,我才选择了如今的职业。除此之外,还有一层不变的对游戏的热爱。我喜欢玩游戏,也曾想过做游戏,在上海的那一段日子,我畏畏缩缩的迈出过第一步(想法/剧本),后来也不了了之。

是时候重新出发,在未来的很长一段时间里,想要摸索着迈出第二步。

完。

分享到 评论

React 实现全局组件

React 实现全局组件

有一个这样的需求:用户进入首页时可能会有不同类型的对话框弹出,默认的情况下所有对话框都是打开的,这很影响用户体验。在无法减少对话框的前提下,需要实现一种机制,能够让对话框依次弹出。

React-Native中,一种不那么好的实现就是使用DeviceEventEmitter(一种类似Node中的事件机制),并且创建一个容器组件用于管理对话框弹出,关闭。最终把容器组件挂载到页面中即可。

为了实现这样的需求,除了消息的收发管理,难点还在于如何把容器组件挂载到应用中,由于React-Native中并不存在DOM,所以要采用其它的方式,确保该容器挂载后,整个App使用阶段不会被卸载即可。

就此打住。

React中实现全局组件的思想与之类似,下面就来实现一个message全局组件。

最终的实现应该是这样使用的:

1
2
3
4
5
6
7
8
9
10
11
...
import message from './message'
class App extends React.Component {
...
handleClick = () => {
message('这是一条message')
}
render() {
return <Button onClick={this.handleClick}>点击</Button>
}
}

容器组件

容器组件用于管理message(提供一系列增删接口),并且渲染到DOM

一个简易的容器组件可以是下面这样:

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
import React, { Component } from 'react';
import './style.css';
class Message extends Component {
state = {
messageList: []
}
add = (content) => {
const { messageList } = this.state
this.setState({ messageList: [content, ...messageList]})
}
remove = (key) => {
const { messageList } = this.state
const result = messageList.filter(item => item.key !== key)
this.setState({ messageList: result })
}
clear() {
this.setState({ messageList: [] })
}
render() {
const { messageList } = this.state
const nodes = messageList.map(item => item.component ? item.component : <div className="message-item">{item.content}</div>)
return (
<div className="message-container">
{ nodes }
</div>
)
}
}

上面实现的容器组件提供了add/remove/clear三个方法来对message进行管理,并最终渲染当前messageList中所有的消息。

接下来需要考虑如何把容器组件渲染到DOM,也可以说是把容器组件插入到DOM中。有多种方式可以选择:

  1. ReactDOM.render(element, container[, callback]),这个方法的作用是把React元素渲染到指定的容器中,也是我们最常用的一种渲染到DOM的方法。
  2. ReactDOM.createPortal(child, container),该方法的作用是将子节点渲染到存在于父组件以外的 DOM 节点中,该方法是这样使用的:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 见:[https://zh-hans.reactjs.org/docs/portals.html]
    render() {
    // React 并*没有*创建一个新的 div。它只是把子元素渲染到 `domNode` 中。
    // `domNode` 是一个可以在任何位置的有效 DOM 节点。
    return ReactDOM.createPortal(
    this.props.children,
    domNode
    );
    }

既然是全局message组件,那么其一个理想的挂载的地方可以是body或者指定的DOM节点。下面,分别实现以上两种方式。

1
2
3
4
5
6
7
8
9
10
Message.init = container => {
// 创建内容容器
let root = document.createElement('div')
if (container) {
container.appendChild(root)
} else {
document.body.appendChild(root)
}
render(<Message />, root)
}

Message上新增了一个静态函数init,调用该函数就会把Message组件渲染到指定的DOM元素或者是document.body上。

这里有一个疑问,Message组件中提供了一系列接口来管理message,但通过以上的方式地区把Message组件挂载到DOM上了,却没法暴露接口供外部方法。这里有多种方式,第一种是ReactDOM.render(element, container[, callback])的返回值是其实是组件实例的引用,有了这个引用,外部就可以调用这些接口。这种方式已经不推荐使用,而推荐的方式也就是第二种方式callback ref,我们可以给组件绑定一个回调类型的ref,而这个callback以组件实例作为参数,我们可以通过这种方式来向外部暴露组件实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Message.init = (container, callback) => {
// 创建内容容器
let root = document.createElement('div')
if (container) {
container.appendChild(root)
} else {
document.body.appendChild(root)
}
// callback
function ref(message) {
callback({
add(content){
message.add(content)
},
remove(key) {
message.remove(key)
},
clear() {
message.clear()
}
})
}
render(<Message ref={ref} />, root)
}

我们给init方法增加了一个callback参数,通过这个callback就可以把组件实例暴露到外部中去。在callback ref中,我们调用init传入的callback参数,并把组件实例作为callback的参数,实际上,暴露整个组件实例是非常危险的,因此这里只是暴露了实例的几个外部调用接口。

外部接口

接下来实现外部接口,也就是我们外部调用的方法message(content)。实现容器组件的时候,我们实现了一个静态方法Message.init,该方法初始化了一个容器并挂载到DOM,并通过callback的方式对外暴露接口来管理messageList。因此,这个外部调用的方式只需要去实现实现这个callback。例如,我们实现的这个message(content)调用后三秒后就会消失。

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
import Message from './message'

let messageInstance;

let index = 0;

function addMessage(content) {
const key = `${index++}_MESSAGE_UNIQ_KEY`
messageInstance.add({ key, content })
setTimeout(() => {
messageInstance.remove(key)
}, 3000)
}

function message(content) {
function callback(instance) {
messageInstance = instance
addMessage(content)
}
if(!messageInstance) {
Message.init(null, callback)
} else {
addMessage(content)
}
}

export default message

上面的代码通过callback拿到了组件对外的接口并对外缓存messageInstance,针对每条消息生成了一个UNIQUE_KEY用于后续的消息移除。这里需要注意的是,Message只能实例化一次。

总结

至此,一个全局组件message已经完成了。当然,如果你想要看到更好的效果,还需要对容器和消息本身添加样式。我们还可以自定义消息组件,将接口通过props传入到组件中,更好的管理消息。

React也提供了ReactDOM.unmountComponentAtNode(container)方法来卸载一个组件,当message组件不再需要的时候,最好将它从DOM中移除。

前面提到过ReactDOM.createPortal(child, container)同样可以将节点渲染到DOM。不多想要这种方式并不是很适合这种场景,因为对外暴露接口是一个难题,这里就不在演示了。

完。

分享到 评论

React-Navigation实现动态Tab路由

有一个需求:在用户未登录和已经登陆的情况下,需要渲染不同的底部导航菜单,而该导航栏其实是react-navigation-tabs的实例,并且不支持动态导航。

这个是一个很常见的需求,在这个issue下面有很多讨论,针对此需求,也提供了一系列解决方案。

动态导航

我们使用createBottomTabNavigator(RouteConfigs, TabNavigatorConfig)来创建tab导航,其中RouteConfigs接受一个导航名称和路由的映射对象,一般情况下,RouteConfigs是确定的,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// tab nav配置
const RouteConfigs: TabOptions = {
Home: { screen: Home },
Purchase: { screen: Purchase },
Brand: { screen: Brand },
Sell: { screen: Sell },
Management: { screen: Management }
}
// 创建底部tab路由
const BottomTabRoutes = createBottomTabNavigator(RouteConfigs, {
...TabNavigatorConfig
})
// 接入到App路由中
const AppNavigator = createStackNavigator(
{
TabRouter: { screen: BottomTabRoutes },
...pageRoutes
}
)

createBottomTabNavigator接收RouteConfigs作为参数,返回一个类型为NavigationContainer的值:

1
2
// NavigationContainer 类型定义
interface NavigationContainer extends React.ComponentClass<NavigationContainerProps NavigationNavigatorProps<any>> {...}

通过查看NavigationContainer的定义,可以发现其是一个React组件。所以第一种方式就是自定义一个组件,在该组件中返回NavigationContainer实例即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DynamicTabNavigater extends Compoent {
_genNav() {
let NavConfig = [....]
// 对`NavConfig`的一系列的处理
return createBottomTabNavigator(NavConfig, { ...otherConfig })
}
render() {
const Tabs = this._genNav()
return <Tabs />
}
}
const AppNavigator = createStackNavigator(
{
TabRouter: { screen: DynamicTabNavigator },
...otherRouter
}
)

这种方式在该issue被证实是可行的,但是在React Navigation3.x版本中报错,显示缺少AppContainer,所以还需要使用createAppContainer创建一个容器。

1
2
3
4
5
...
render() {
const Tabs = createAppContainer(this._genNav())
return <Tabs />
}

这样动态路由就实现了,并且能够在绝大部分情况下使用正常,由于使用createAppContainer创建了一个容器,如果该容器并无法包含所有路由,那么还需要的AppContainer,此时就会导航异常。

实现二

继续关注createBottomTabNavigator(RouteConfigs, TabNavigatorConfig)方法,第二个参数TabNavigatorConfig包含一个属性tabBarComponent?: React.ReactType,该属性用于设置tabBar如何显示,该属性设置为一个组件。

所以机会来了!

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
import * as React from 'react'
import { BottomTabBar } from 'react-navigation-tabs'
import { DeviceEventEmitter } from 'react-native'

interface NavigatorState {
showBrandPage: boolean
}

class DynamicTabNavigator extends React.Component<any, NavigatorState> {
state: NavigatorState = {
showBrandPage: true
}
subscribe: any
componentDidMount() {
this.subscribe = DeviceEventEmitter.addListener(
'showBrand',
(data: boolean) => {
this.setState({ showBrandPage: data })
}
)
}
componentWillUnmount() {
// tslint:disable-next-line: no-unused-expression
this.subscribe && this.subscribe.remove()
}
_tabNav = () => {
const { routes, index } = this.props.navigation.state
const finalRoutes = [...routes]
const { showBrandPage } = this.state
// ...一系列的操作
return {
state: {
index: finalRoutes.findIndex(route => currentRoute.key === route.key),
routes: finalRoutes
}
}
}
render() {
const { navigation, ...restProps } = this.props
const tabNavConfig = this._tabNav()
return <BottomTabBar {...restProps} navigation={tabNavConfig} />
}
}

export default DynamicTabNavigator


const BottomTabRoutes = createBottomTabNavigator(tabNav, {
tabBarComponent: DynamicTabNavigator
})

通过eventListener的方式接收路由变更信号,最终渲染BottomTabBar时使用修改过后的配置即可。

这种方式其实是一种障眼法,我们需要在配置静态Tab路由RouteConfigs时配置所有的Tab路由,在DynamicTabNavigator中通过props注入的navigation.state.routes拿到配置的静态路由,并经过一系列的处理最终得到渲染到BottomTabBar中的路由。需要注意的是,如果最终的路由相比静态路由有调整,那么需要更新index,否则点击路由跳转时会出现错误。

总结

针对React Navigation无法支持动态路由的问题,以上给出了两种方案,能够在一定程度解决。

  1. 第一种方案按需挂载路由,可以算是“真”动态路由;
  2. 第二种方案从可定制的tabBarComponent入手,不改变路由配置,而是在渲染层进行控制,条件渲染BottomTabBar
分享到 评论

从@babel/preset-env谈多浏览支持构建

@babel/preset-env谈多浏览支持构建

最近需要把一个在特定浏览器环境运行的Web应用移植到多浏览器,特别是要支持部分IE浏览器。项目打包完成,在IE 11下运行,并不能成功,提示Map()未定义。很显然,IE浏览器并不支持ES6语法,而在构建中也没有使用相应的填补

在基于Webpack + Babel构建的应用中,我们一般会使用到@babel/preset-env这个包,它使用了各种工具转译我们编写的ES6代码,我们一般这么使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// .babelrc
{
"presets": ["@babel/preset-env"]
}
// 或
// webpack.config.js
{
...
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
options: {
presets: [
"@babel/preset-env"
]
}
}
]
}
...
}

不做额外配置的情况下,@babel/preset-env并不知道哪些ES6+的语法需要转译,所以最终的结果就是并没有转译。

@babel/preset-envtargets属性

为了兼容多浏览器,我们需要告知哪些应用构件时需要支持哪些浏览器,@babel/preset-env提供了targets属性进行配置,例如为IE 10构建,可以这样配置:

1
2
3
{
targets: "ie 10"
}

我们可以使用browserslist包来指定构建的目标浏览器,可以在.browserslistrc或是package.json中进行配置:

1
2
3
4
5
6
"browserslist": [
">0.2%",
"not dead",
"not ie <= 10",
"not op_mini all"
],

上面的配置中包含4条查询语句,>0.2%表示大于0.2%的市场份额,not dead表示近24个月还在支持的浏览器,所有查询语句见npm

配置完成后,可以运行npx browserslist查看具体支持的浏览器版本。

必不可少的useBuiltIns属性

设置browserslist后构建的代码仍然不能很好的运行,这是因为项目中并没有加入polyfill,并且也未告知@babel/preset-env该如何处理pliyfilluseBuiltIns正是用来解决这一问题。

1
useBuiltIns: false | "entry" | "usage"

useBuiltIns设置为"entry" | "usage"的时候,@babel/preset-env将会使用core-js提供的填补。

"entry"的意思就是,当我们在某个文件中import 'core-js'但是该文件中只使用了到了ES6中的String.prototype.padStart方法,那么就上一句import就会在构建的时候被替换成import 'core-js/modules/es.string.pad-start'

"usage"的作用同其字面意义,即为:按需加载。如果我们在某个文件中使用了Map,如果构建目标支持Map,那么就不会使用相应填补,否则会在构建时加上import 'core-js/modules/es.map'

现在设置useBuiltIns: "usage"

core-js及其使用

core-js是一个ES6+语法的polyfill,简单而言就是使用ES3的语法实现了到目前为止几乎所有ES新特性。并且可以按需加载且不会污染全局命名空间。

core-js2.x3.x两个版本,其区别就是2.x不支持目前最新的语法,这里可以按需选择2.x或者3.x版本安装。

安装core-js

1
yarn add core-js

使用core-js的需要注意的是该包需要在入口文件顶部导入,因为只有这样填补才会被完全用到。
对已使用webpack构建的项目可以在config中在入口中引入:

1
entry: ['core-js/stable', 'index.js']

也可以在入口文件(一般为src/index.js)的顶部引入:

1
2
// index.js
import 'core-js/stable'

引入完毕,再进行构建,不出意外已经能够在指定版本的浏览器中运行了。

其它

ES6+语法的多浏览器兼容的确告一段落了,但是浏览器APICSS兼容还有很多问题,浏览器兼容才刚刚开始。

比如,IE并不支持fetch API,所以要么我们需要一个polyfill,要么就修改业务代码。

1
yarn add whatwg-fetch

然后在入口文件中引入,

1
import 'whatwg-fetch';

某些CSS3支持得也不够好,需要我们一个个去考虑。

兼容性是前端开发无法规避的问题,而解决兼容的过程是”痛苦的”,特别是当业务开发完成后才考虑兼容的问题,痛苦加倍。

痛苦并快乐着。

完。

分享到 评论

从globalCompositeOperatio到蒙版弹幕

globalCompositeOperation蒙版弹幕

globalCompositeOperation属性

Canvas 有一个不那么常用的属性globalCompositeOperation,作用是设置如何将一个源图像绘制到目标图像上。该如何理解?

在使用Canvas绘制图像时,我们可以多次调用ctx.drawImage()或者是其它绘图函数ctx.fillRect()等进行绘制。而globalCompositeOperation属性就指定了当前将要绘制的图像在画布上如果和已绘制的图形重合该怎样显示。该属性有多个可选值:

  • source-over默认值,目标图像重合部分将显示在上方(源图像被覆盖)。
  • source-in目标图像中显示源图像。源图像只显示重合的部分,目标图像透明。
  • source-out在目标图像之外显示源图像。只会显示非重合的部分,目标图像透明。
  • lighter 显示源图像+目标图像。
  • copy 只显示源图像。
  • xor 亦或。
  • destination-*

例如:当设置属性值为source-over时,下例将会显示为:

1
2
3
4
5
6
7
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'red';
ctx.fillRect(10, 10, 80, 40);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'green';
ctx.fillRect(20, 20, 80, 40);

source-over

可以看到,后绘制的绿色图像(源图像)和先绘制的红色图像(目标图像)发生了重叠,而源图像在层叠上层。

再来看看属性值为source-in的情况:

source-in

源图像和目标图像发生了重合,结果只显示了源图像的重合部分。

蒙版弹幕

不考虑弹幕内容、显示等因素,使用Canvas实现弹幕就是一个不断擦除和绘制的过程,弹幕本身是绘制在画布上的,和内容(视频、图片等)是分层显示的,并无直接关系。

Canvas弹幕且不论性能,如果弹幕过多往往会挡住内容本身,体验并不好。B站的弹幕使用了名为蒙版弹幕的技术,这种技术可以让弹幕不遮挡内容主体。这里不讨论B站蒙版弹幕是如何实现的,先来看看CSS中一个名叫mask的属性。

mask属性用来设置遮罩,那么何为遮罩呢?简单点来说就是使用一张图片来遮住另一张图片,并且如果用于遮罩的图片包含透明的部分,透明部分将会被遮住,非透明部分将会显示为被遮罩图片的内容。

mask的内容到此为止,是不是和globalCompositeOperation = 'source-in'很像?其实我认为不是很像。

修改上面的例子,如果将两个图形绘制的区域完全重合,那么设置globalCompositeOperation = 'source-in'后,不出意外,源图像将会完全覆盖目标图像。

如果我们把目标图像和源图像均换成两张等宽高的图片,那么源图片将会完全遮挡目标图片。

如果目标图像和源图像存在透明区域(RGBAAlpha0的区域),那么源图像会完全遮住目标图像,但是目标图像的透明区域仍然是透明的。

如果反向抠图后,目标图像只有主体是透明的,那么源图像将会覆盖目标图像的非主体区域,主体区域由于是透明的,无能为力。

把目标图像换成蒙版图片,把源图像换成包含弹幕的图像,那么,蒙版图像透明区域不会被覆盖。

此时再把覆盖后的图片渲染在Canvas上,大功告成。

回顾一下globalCompositeOperation = 'source-in'的解释:
目标图像中显示源图像。源图像只显示重合的部分,目标图像透明。
目标图像会被完全覆盖,而源图像只显示重合的部分,由于透明区域并不属于目标图像,所以在透明区域并不会显示源图像。

先来看一个例子:

1
2
3
4
5
6
7
8
9
10
ctx.fillStyle = 'red';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 150);
ctx.lineTo(150, 0);
ctx.fill();
ctx.moveTo(150, 0);
ctx.lineTo(300, 150);
ctx.lineTo(300, 0);
ctx.fill();

300*150的画布上先绘制两个红色的三角形,作为目标图像,此时画布中间的区域是透明的。

目标图像

1
2
3
ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = 'green';
ctx.fillRect(0, 0, 300, 150);

然后再绘制一个充满画布的矩形覆盖到目标图像上,此时的结果是这样的:

覆盖结果

目标图像(两个红色的三角形)已经被完全覆盖了,而透明区域仍然透明。

简易实现

为了实现蒙版弹幕,需要准备:

  • 原版图像
  • 蒙版图像
  • Canvas弹幕

这里原版图像使用下面这张菊花图

原版图片

经过抠图(主体变成透明),生成的蒙版图片如下:

蒙版图像

弹幕系统的实现不做过多的介绍,这里只关注绘制,和上例的绘制过程一致,首先绘制蒙版图像,再绘制弹幕内容到蒙版上进行覆盖。

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
drawBarrages() {
let context = this.useMask ? this.bgCtx : this.ctx;
if(this.barrageList.length) {
context.clearRect(0, 0, this.width, this.height);
for(let i=0; i < this.barrageList.length; i++) {
// 此时弹幕将要移除画布
let barrage = this.barrageList[i];
if(barrage.left + barrage.width <= 0) {
this.barrageList.splice(i, 1); // 移除该弹幕
i -= 1;
continue;
}
if((barrage.left + barrage.width+600) < this.width && barrage.isChecked === false) {

// 此时已经完全出跑道并且还没有发起检查。
let index = vm.statics.findIndex((item) => item === barrage.top);
barrage.isChecked = true;
used[index] = 0;
}
barrage.left = barrage.left - barrage.offset;
this.drawOneBarrage(barrage);
}
// #1 擦除上次绘制
this.ctx.clearRect(0, 0, this.width, this.height);
// #2 绘制蒙版图像
this.ctx.putImageData(this.maskImage, 0, 0);
// #3 设置compose类型
this.ctx.globalCompositeOperation = 'source-in';
// #4 绘制弹幕
this.ctx.drawImage(this.bgCanvas, 0, 0, this.width, this.height);
}
this.stop = requestAnimationFrame(this.drawBarrages.bind(this));
}

上面的代码是完整绘制一帧的逻辑,跳过绘制弹幕文本的循环,关注整体绘制和层叠逻辑。其中绘制总分为四步,擦除上一帧结果,绘制蒙版(作为目标图像),设置compose类型为source-in,绘制弹幕(作为源图像)。

注意到,蒙版图像使用ctx.putImageData绘制,这表示蒙版图像是ImageData类型,可以通过ctx.getImageData()拿到(需先绘制在画布上),这里也可以png图片作为蒙版图片直接绘制在画布上。

this.ctx.drawImage(this.bgCanvas, 0, 0, this.width, this.height)用于绘制弹幕,this.bgCanvas是绘制了弹幕文本的Canvas画布,这里使用了离屏Canvas 技术,该画布并不会单独绘制在屏幕上。

绘制结果如下图:

弹幕绘制结果

可以看到,弹幕并没有遮挡我们的主体(菊花),实现了蒙版弹幕的预期效果。

尾巴

我们的确实现了蒙版弹幕,其主体是使用Canvas绘制蒙版图片和弹幕在同一张画布上,并且设置globalCompositeOperation = 'source-in'来达到弹幕完全覆盖蒙版的效果。

简易实现的内容主体是一张静态的图片,如果要实现视频蒙版弹幕效果,需要提供每一帧的蒙版图像。在渲染某一帧时,最终的结果使用该帧的蒙版图像和实时弹幕组合而成。

其实蒙版弹幕的关键是提供蒙版图像,对于单个图片还好,我们可以针对这张图片单独制作一张蒙版图像。但是对于视频蒙版弹幕,我们需要逐帧生成蒙版图像,工作量之大可想而知。并且如何生成蒙版图像,如何标注主体是关键中的关键。这一部分理应借助机器学习,进行图像识别和分割。

一个可行的办法是事先针对每个视频,先逐帧生成蒙版图像,在进行流媒体播放的时候同时传递蒙版图像,最终在前端进行组装,完成视频蒙版弹幕

完。

分享到 评论

记一次React Hooks的使用

有这么一个需求:需要渲染一个表格,表格的内容会随着用户的操作而重新请求数据,并且在用户离开这个表格所在的页面(路由)后缓存数据,再次进入该页面的时使用已缓存数据。

目前在使用的表格组件是纯函数组件,只负责渲染,数据请求则写在其父组件,数组则存在Redux中。经过考虑,需要把数据请求的逻辑移入表格组件中,使得表格组件承担更多的职责,在React 16.8之前,我们不得不把表格组件写成class组件。

而现在,可以使用hooks,以最少的更改,来实现这一需求。

需要哪些hooks?

我们的需求是把请求数据的逻辑移入到表格组件中,表格的数据仍然保存在Redux中。众所周知,数据获取是一个有副作用的操作,而useEffect这个hooks就是用来处理有副作用的操作。

在使用hooks之前,我们一般在componentDidMountcomponentDidUpdate或者是很少使用的componentWillUnMount来进行DOM操作,数据请求等副作用操作。而useEffect则可以简单的看做是这三个生命周期函数的合集,其在组件的这三个生命周期时,都会被调用到。

所以,使用useEffect解决了所有问题。

“真”解决了所有问题?

先把代码写起来:

1
2
3
4
5
6
7
8
9
10
import React, { useEffect } from 'react';
const MyTable = ({ fetchData, param }) => {
useEffect(() => {
fetchData(param);
}, [param])
return (
<Table />
);
}
export default connect(stateToProps,actionToProps)(MyTable);

仅仅三行代码,就搞定了。更改params,一切正常;从其它页面进入,看起来也很正常。可为什么是看起来正常呢?因为你打开控制台,查看network xhr,再进入页面,请求发送了!并没有使用缓存的数据!

所以,问题并没有解决。

如何更好的利用缓存数据

问题:既然useEffect能够在上述三个生命周期中都执行,那么有没有办法区分出首次渲染和更新呢?

答案是肯定的!在上面的代码中,我们使用了useEffect(func, [param])的形式,其实useEffect的第二个参数如果指定,那么useEffect就不是每次都执行了,而是只有param改变了才会执行。并且特别的,如果第二个参数传入[]空数组,那么useEffect只会执行一次,也就是说,useEffect只会在componentDidMount执行!代码写起来:

1
2
3
4
5
6
// 其它代码不变,再加一个hook
useEffect(() => {
if (data.status !== 'fulfilled') {
fetchData(param);
}
}, [])

上面的hook会在初次渲染完成后执行,如果缓存数据的状态不是fulfilled,才请求数据。

问题仍然没有解决

很显而易见的是,即便是加了一个hook,而第二个useEffect仍然会执行,所以仍然会在初次加载完成后请求数据。

这时候,就需要另一个hook出场了,那就是useState,我们需要在组件中维持一个是否是首次渲染的状态,只有当非首次渲染的时候,才会去执行第一个hook,因此可以避免不必要的的数据请求。代码码起来:

1
2
3
4
5
6
7
8
9
10
11
12
const [isInitial, changeInitialToFalse] = useState(true);
useEffect(() => {
if (!isInitial) {
fetchData(param);
}
}, [param]);
useEffect(() => {
changeInitialToFalse(false);
if (data.status !== 'fulfilled') {
fetchData(param);
}
}, []);

经过测试,这一次是真一切正常了,缓存也已经用上。

尾巴

这是一次再普通不过的组件改造,这也是我在实际项目中第一次使用hooks。并且经历了从最开始遇到需求到决定使用hooks来最小化修改,到遇到问题差点改成更熟悉的class组件,到最终解决问题。最大的收货是,我对useEffect的了解又深刻了一些。把数据请求逻辑移到组件内部,除了降低组件间的耦合,更大程度上可以配合前一篇提到的ScrollLoad来做真正的滚动加载。毕竟数据才是组件的灵魂,数据都不懒加载,组件懒加载的意义就减了一半,手动狗头。

完。

分享到 评论

Webpack resolve解析

虽然在实际开发过程中,我们经常会使用脚手架来初始化一个项目,而脚手架一般都包含了完善的webpack配置,例如create-react-app这个脚手架,其把所有关于构建的内容封装在了react-scripts包中,在实际开发中,我们只需要运行yarn run start/build/test即可,它就可以帮我们搞定代码压缩、分隔,jsxes6代码编译到es5等。

回到webpack,我们都知道一个完整的webpack配置必定是要包含入口(entry)输出(output),可能还需要模块-加载器(loader)来处理不同类型的模块、或是使用插件(plugin)来在构建过程中自定义某些动作。除此之外,还有webpack4中才引入的用于性能和构建优化的optimization,还有用于开发环境的开发服务器(devServer)。还有不那么常用和深入人心的解析(resolve)。本篇将以react-scripts包的webpack配置中关于resolve的使用为基础,介绍如何在实际项目中可能会用到的自定义解析。

他们是怎么写的

首先来看看react-scripts关于resolve的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
resolve: {
modules: ['node_modules', paths.appNodeModules].concat(
modules.additionalModulePaths || []
),
extensions: paths.moduleFileExtensions
.map(ext => `.${ext}`)
.filter(ext => useTypeScript || !ext.includes('ts')),
alias: {
'react-native': 'react-native-web',
},
plugins: [
PnpWebpackPlugin,
new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
],
},

  1. modules属性:其指定了webpack在进行模块解析时应该搜索的目录,该属性可通过数组的方式指定一系列的路径,默认值是: ["node_modules"],也就是说,在不指定该属性的情况下,如果我们引入import xx from xx,则webpack会默认在根目录的node_modules目录下查找该模块。上面的配置中指定了额外两种模块解析路径,其分别是path.appNodeModulesmodules.additionalModulePaths,其中path.appNodeModules最终指向的是path.resolve(fs.realpathSync(process.cwd()), 'node_modules')也就是说,在默认情况下,该路径是node_modules决定路径modules.additionalModulePaths指向一个自定义路径,并通过getAdditionalModulePaths(config)方法生成该路径,如果config等于{},则返回process.env.NODE_PATH(经过一些列的处理),否则的话如果config.baseUrl存在且等于modules.additionalModulePaths或者appSrc则返回,否则抛出错误。也就是说,我们可以通过jsconfig.json来指定baseUrl属性,并且该属性只能是node_modules目录或src目录。

  2. extensions属性:自动解析的确定的扩展,默认值是['.js', '.json'],也就是说,在默认情况下,我们import ClsA from './clsa',可以解析到clsa.js或是cls.json。上面的配置重写了extensions,定义了更多的扩展,并根据当前是否是typescript项目而是用对应的拓展。paths.moduleFileExtensions定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const moduleFileExtensions = [
    'web.mjs',
    'mjs',
    'web.js',
    'js',
    'web.ts',
    'ts',
    'web.tsx',
    'tsx',
    'json',
    'web.jsx',
    'jsx',
    ];

如何判别当前项目是typescript项目呢?
useTypeScript是这样定义的:const useTypeScript = fs.existsSync(paths.appTsConfig);,很明显,通过判断是否存在paths.appTsConfig指向的文件也就是tsconfig.json,如果存在该文件,则表示该项目是中可以引用那些以ts为后缀的文件而不需要指定扩展名。

  1. alias属性:创建模块的别名,确保在引入某些模块时可以变得简单。在上面中alias的配置是:
    1
    2
    3
    alias: {
    'react-native': 'react-native-web',
    },

也就是在我们使用名为react-native的模块时,其默认指向的是react-native-web模块,例如:

1
import { View } from 'react-native';

此时项目中根本没安装react-native,仅安装了react-native-web,也就是说react-native成了react-native-web的别名。那么问题来了:为什么我们不直接写import { View } from 'react-native-web'呢?

其实这涉及到React-Native强调的一次编写,处处使用,这里的使用并不仅仅是iOSAndroid代码共用,而是React-NativeWeb之间的代码共享。react-native-web就是这样一个库,它把react-native实现的组件实现成为Web组件,并且表现和react-native组件一致。这样,如果我们拿到的是一份react-native的代码,添加别名过后,就无需把所有的react-native全都改成react-native-web,这样就保证了两端代码的统一。

扯远了,继续。

  1. plugins属性:如果在配置解析的过程中需要插件的话,就可以在这里指定。上面的代码使用了pnp-webpack-pluginreact-dev-utils/ModuleScopePluginpnp-webpack-plugin是为了解决require()时过多的I/O操作带来的性能消耗,pnp思想来自Yarn团队,目的是为了解决安装和引用依赖效率过低问题。其建立了一张映射表,这张表记录了依赖版本关联和依赖与依赖之间的关联以及依赖存放的位置。有了这张表,就可以跳过繁琐的查找过程直接确定依赖在文件中的位置,从而提高性能。详情见stackoverflowModuleScopePlugin插件的官方解释是:该插件可以确保来自源程序目录(也就是/src)的相对导入不会使用到外部依赖。new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson])接收两个参数,分别指定了源程序目录/srcallowedFiles指向了package.json文件。

更多的配置

  1. mainFields属性:该属性是为了指定在不同环境中,默认使用哪个webpack的字段作为导入的入口。例如某个模块的package.json文件中执行了以下入口:
    1
    2
    3
    4
    5
    {
    module: 'index.js',
    main: 'build/index.node.js',
    browser: 'build/index.js',
    }

那么在Node环境下,默认会使用build/index.node.js导入,而在浏览器环境中,默认使用build/index.js导入。

  1. mainFiles属性:解析目录是默认使用的文件名。这个在实际开发中使用得比较多,例如我们开发的某个页面,文件路径为/src/views/Home/index.js,那么在实际使用这个页面的时候直接使用import Home from './src/views/Home'即可,因为mainFiles默认的配置是:['index'],这个属性不建议自定义。

resolve还有很多属性,可以让我们充分自定义整个解析过程,但从react-scripts的实践上来看,其也是针对某些属性进行了定制,并没有一味的自定义。在对某个属性不是很熟悉并且没有过实践,建议不要盲目的修改。并且resolve的所有属性都提供了适应绝普通场景的默认值。最后,resolve使用愉快。

完。

分享到 评论

来,实现一个“滚动加载”

从问题入手,实现一个滚动加载

实现原理

懒加载的实现很简单,例如图片需要懒加载的时候,在初始加载时并不直接加载图片,而是用一个其它的图片或者占位符占位,当其它优先级更高的内容加载完成后,再使用真实的图片替换占位图。具体呢就是在初始加载时并不知道图片的src或者src指向一个占位图片,而真实图片的路径则是放在data-src这样的自定义属性中,当需要加载时,使用data-src替换src即可。

在React中,如果我们需要懒加载一个组件,实现原理也类似:当不需要加载的时候渲染占位符,当需要加载的时候再去加载真正的加载。滚动加载就是懒加载的一种特殊情况,其触发方式是滚动,只有当组件在可视区域中时,才开始加载。

实现滚动加载并不简单,我们需要考虑以下几个问题:

  1. 如何找到组件所处的滚动容器。
  2. 如何判定组件是否可见。
  3. 如何使用“更好”组件占位符。
  4. 处理性能瓶颈。

ScrollLoad实现

懒加载使用最为广泛的实现是react-lazy-load,使用起来也很方便:<LazyLoad><MyComponent /></LazyLoad>,使用提供的LazyLoad组件包裹需要懒加载的组件即可,并且它提供了多个属性,例如height可以设置当内容未加载时的高度,offset则可以指定组件开始加载时需要偏移的距离(单位:px),除此之外,我们还可以指定懒加载是否使用防抖和节流来提升性能,具体的使用见react-lazy-load

虽然react-lazy-load是一个使用广泛的懒加载解决方案,但是在最近的项目中,我却不得不放弃使用它。因为react-lazy-load会更改原有的DOM结构!!!,所以如果要使用react-lazy-load,我必须更改原本的样式,这将会是一个浩大的工程。

所以,接下来将会实现一个不需要额外DOM结构的滚动加载组件ScrollLoad组件。

问题1:如果找到组件所在的可滚动元素

这个问题其实是:如何找到组件所在的最近的可滚动的父元素

但是为什么需要找到那个可滚动的父元素呢?因为需要在该父元素上绑定scroll eventListener,当父元素在滚动时,就可以根据监听函数实时获取滚动的距离,并依此决定组件显示与否。

那怎么样才能找到最近可滚动父元素呢?为了定位父元素,我们需要定位当前元素,然后再向上遍历,去查找overflow显式设置为auto|scroll的元素。

查找可滚动父元素代码如下:

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
function getScrollParent(element) {
const style = (elem, prop) => {
if (getComputedStyle !== undefined) {
return getComputedStyle(elem, null).getPropertyValue(prop);
}
return elem.style[prop];
};
const overflow = node => style(node, 'overflow') + style(node, 'overflow-x') + style(node, 'overflow-y');
// 循环判断父节点是否可滚动这里暂不添加,直接去直接父元素
if (!(element instanceof HTMLElement)) {
return window;
}
let parent = element;
while (parent) {
// 当前节点是body或者document
if (parent === document.body || parent === document.documentElement) {
break;
}
// 当期元素无父节点
if (!parent.parentNode) {
break;
}
// 判断节点是否含有overflow等属性的值
if (/(scroll|auto|inherit)/.test(overflow(parent))) {
return parent;
}
parent = parent.parentNode;
}
return window;
}

实现参考了react-lazy-load的实现,通过当前节点向上查找直到找到第一个可滚动的父元素或遍历到顶层元素,并且每进行一次遍历,会通过正则检查当前元素的样式的overflow overflow-x overflow-y,如果为scroll|auto,则返回该父元素。

那么怎么得到当前元素呢?可以通过ReactDOM.findDOMNode(component)访问真实的DOM节点,如果有多个子节点的话,默认返回第一个。因为ScrollLoad组件并没有添加额外的DOM结构,所以通过findDOMNode(this)拿到的节点就是目标节点,也就是占位节点(因为就算组件初始状态下可见,那么也要等应用挂载后才去判断,所以首次渲染的是占位组件)。

问题2:如何判定组件是否可见

如果只考虑上下滚动的话,一个很简单的判断公式是:offsetTop < seenHeight + scrollTop,就是:组件相对于可滚动父元素的偏移 < 可滚动父元素的可视高度 + 可滚动父元素的滚动距离。上面的三个计算量中offsetTopseenHeight都是固定不变的,所以一个组件是否可见取决于父元素当前滚动的距离。seenHeight很好计算:parent.clientHeight即可,scrollTop也很简单:parent.scrollTopoffsetTop计算稍微复杂。

如何计算offsetTop?如果最近可滚动父元素是直接父元素的话,直接通过elem.offsetTop就可以得到,如果包含多层嵌套,那么offsetTop就需要每一层元素相对于父元素的offsetTop相加,直到父元素等于目标父元素。简单的实现如下:

1
2
3
4
5
6
7
8
9
10
// 该函数没有考虑节点异常的情况
let getNodeOffsetTop = (node, parent) => {
let current = node;
let offsetTop = 0;
while (current !== parent) {
offsetTop += current.offsetTop;
current = current.parentElement;
}
return offsetTop;
}

最后是判断组件是否可见的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let checkVisible = (node, parent) => {
if (!node || !parent) {
this.setState({ visible: true });
return null;
}
return () => {
const { visible } = this.state;
if (visible) {
this.parent.removeEventListener('scroll', this.scrollHandler);
return; // 直接返回不执行当次eventListener
}
let seenHeight = parent.clientHeight;
let scrollHeight = parent.scrollTop;
let currentNode = findDOMNode(this); // 获取最新的dom结构
let offsetTop = this.getNodeOffsetTop(currentNode, parent);
// 1. 当偏移高度小于可见高度
// 2. 初始不可见的时候,当可视高度+滚动高度大于了偏移高度
if (offsetTop <= seenHeight + scrollHeight) {
this.setState({ visible: true });
}
};
}

问题3:如何使用“更好”组件占位符

理想情况下,一个好的占位组件应该是和真实组件一样大的size,这样的话初始情况和加载完成的情况下滚动条的长度都是一样长的,且在组件由不可见到可见这个过程中页面并不会因为组件前后的size而出现抖动。

而实际的情况是,我们并不能很好的拿到目标组件的样式并作用到占位组件上,因为占位组件总是先渲染。所以折中的做法是让ScrollLoad的使用者去提供占位组件,这样就把如何提供一个好的占位组件交给了使用者。

还有一种办法是,既然一个好的(只是我认为的)占位组件的size是等于目标组件的,那么我直接把用于布局的样式从目标组件上拿过来不就行了!所以无论是目标组件渲染后的真实DOM上的className id style...全部拿过来,如果高度是内容撑开的话,我们就拿渲染完成的高度直接设置到占位组件上。经过尝试,这种方法的确可行,但是需要付出的代价是需要花费时间在获取目标组件的样式和样式整理上,并且代码的可读性将一定程度的降低。

所以,关于占位组件,我暂时没有好的方法。

问题4:性能瓶颈

在前面介绍react-lazy-load的时候提到过我们可以决定是否使用节流或防抖来解决性能问题。所以,在ScrollLoad上,也是用了节流来控制scroll触发的频率。代码如下:

1
2
this.scrollHandler = throttle(this.checkVisible(dom, parent), 100);
parent.addEventListener('scroll', this.scrollHandler, { passive: true });

这里使用了lodash/throttle来实现节流,这样默认情况下,scroll事件只会每隔100ms触发一次。

上面的代码在使用addEventListener绑定监听函数时还是用到了该函数的第三个参数:{ passive: true }passive的意思是消极的、被动的,如果不指定,在新版的Chrome中会有性能提示:[Violation] Added non-passive event listener to a scroll-blocking <some> event. Consider marking event handler as 'passive' to make the page more responsive.

其实在监听滚动事件是,我们可以通过event.preventDefault()来阻止浏览器的默认滚动行为,可当滚动触发时,浏览器并不知道我们的监听函数中是否阻止了默认行为,所以浏览器会等待,直到监听函数执行完,此时浏览器才会选择滚动与否。而执行监听函数往往需要时间,性能就会受到影响。

而设置{ passive: true },可以在执行监听函数之前就告诉浏览器,并没有阻止默认滚动,因此在滚动触发时,浏览器就不会等待,直接滚动。显然ScrollLoad是需要执行浏览器滚动的,因此设置{ passive: true }可以提升性能。

checkVisible函数中,有一段代码:

1
2
3
4
const { visible } = this.state;
if (visible) {
this.parent.removeEventListener('scroll', this.scrollHandler);
}

这段代码很好理解,除了在组件卸载之前需要移除listener之外,一旦当某个组件可见,那么此时就没必要再监听滚动了,所以需要移除监听函数。

上面的代码还有一个问题,如果当某两次触发监听函数组件的状态刚好从visiblefalse切换到true,此时移除了监听函数,但当次函数还会再次执行,所以在移除监听函数后,直接返回,可以避免执行下面不必要的逻辑。因此,这段代码应该是:

1
2
3
4
5
const { visible } = this.state;
if (visible) {
this.parent.removeEventListener('scroll', this.scrollHandler);
return; // 直接返回不执行当次eventListener
}

结尾

到此为止,一个基本可用的ScrollLoad组件就实现了,其实对于不同的使用场景,ScrollLoad的实现也可以略有不同,例如如果当前需要scrollload的组件是一些列表项组件,每个组件的样式外观都是一致的。这样的话,我们在写lazyload组件的时候,就可以把所有组件使用一个LazyLoad组件包裹起来,然后绑定一个监听函数,使用可显示的组件个数作为组件状态,每次滚动时监听函数根据已经滚动的高度去计算可见组件的个数,最后在渲染的时候遍历this.props.children,选择渲染组件或是占位组件即可。

LazyLoad实现代码:

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import React from 'react';
import { findDOMNode } from 'react-dom';
import throttle from 'lodash/throttle';
import { Spin } from '@xx/xx-ui';
import ReactPlaceHolder from 'react-placeholder';

function getScrollParent(element) {
const style = (elem, prop) => {
if (getComputedStyle !== undefined) {
return getComputedStyle(elem, null).getPropertyValue(prop);
}
return elem.style[prop];
};
const overflow = node => style(node, 'overflow') + style(node, 'overflow-x') + style(node, 'overflow-y');
// 循环判断父节点是否可滚动这里暂不添加,直接去直接父元素
if (!(element instanceof HTMLElement)) {
return window;
}
let parent = element;
while (parent) {
// 当前节点是body或者document
if (parent === document.body || parent === document.documentElement) {
break;
}
// 当期元素无父节点
if (!parent.parentNode) {
break;
}
// 判断节点是否含有overflow等属性的值
if (/(scroll|auto|inherit)/.test(overflow(parent))) {
return parent;
}
parent = parent.parentNode;
}
return window;
}

let EmptyCompBox = ({ ...props }) => (
<div {...props}>
<Spin size="large" className="lazyload-center-spin" />
</div>
);

class ScrollLoad extends React.Component {
state = {
visible: false,
};
componentDidMount() {
let dom = findDOMNode(this); // 取得当前节点
let parent = getScrollParent(dom);
this.parent = parent;
let visible = this.checkVisible(dom, parent); // 初始化检查是否可见
visible();
this.scrollHandler = throttle(this.checkVisible(dom, parent), 100);
parent.addEventListener('scroll', this.scrollHandler, { passive: true });
}
componentWillUnmount() {
this.parent.removeEventListener('scroll', this.scrollHandler);
}
getNodeOffsetTop = (node, parent) => {
let current = node;
let offsetTop = 0;
while (current !== parent) {
offsetTop += current.offsetTop;
current = current.parentElement;
}
return offsetTop;
};
checkVisible = (node, parent) => {
if (!node || !parent) {
this.setState({ visible: true });
return null;
}
let seenHeight = parent.clientHeight;
let scrollHeight = parent.scrollTop;
return () => {
const { visible } = this.state;
if (visible) {
this.parent.removeEventListener('scroll', this.scrollHandler);
return; // 直接返回不执行当次eventListener
}
let currentNode = findDOMNode(this); // 获取最新的dom结构
let offsetTop = this.getNodeOffsetTop(currentNode, parent);
// 1. 当偏移高度小于可见高度
// 2. 初始不可见的时候,当可视高度+滚动高度大于了偏移高度
if (offsetTop <= seenHeight + scrollHeight) {
this.setState({ visible: true });
}
};
};
render() {
const { visible } = this.state;
const { id, className, style } = this.props;
return (
<ReactPlaceHolder
ready={visible}
customPlaceholder={<EmptyCompBox id={id} className={className} style={style} />}
>
{this.props.children}
</ReactPlaceHolder>
);
}
}

export default ScrollLoad;

分享到 评论

关于eject需要知道的

整理:使用create-react-app构建的项目在yarn run eject后的一些问题。

eject之前

create-react-app脚手架把关于webpack配置和其它脚本封装到了一个叫做react-scriptspackage里。默认情况下所有的配置是不可见的,但是可以通过react-app-rewired在不eject的条件下修改某些webpack配置。这样的方式在绝大多数情况下是可行的,但是如果我们想更精确的控制,比如修改默认的css打包方式、使用less、组件库按需加载等,即使也能够做,但或多或少会用到hack的方式。

在运行yarn run eject后,所有关于webpack配置将会暴露出来,在根目录会生成configscripts两个目录,并且react-script将不复存在,所有react-script的依赖都将注入到项目中。

eject是单项操作,一旦完成,就没办法还原,这就意味着复杂的webpack管理将交由我们自己管理。

eject进行时

  1. 步骤1 运行yarn run eject,稍等片刻,观察到生成新增了configscripts两个目录,并且package.json文件被修改。

  2. 打开package.json文件,由于我们已经不使用react-scripts了,所以这里需要修改start build test等命令。使用scripts目录中对应的脚本即可,例如:

    1
    2
    3
    4
    5
    6
    "scripts": {
    "start": "node scripts/start.js",
    "build": "node scripts/build.js",
    "test": "node scriprs/test.js"
    ...
    }
  3. 运行yarn start,不出意外的话,项目正常运行,如果提示can’t find module xxx,删除node_modulesyarn.lock(如果有的话),重新运行yarn install安装依赖即可。

eject后出现了问题

如果是eject后运行项目报错,重新安装依赖后仍然报错,那么就要考虑是依赖版本的问题了。就比如笔者的这个项目,使用老版本的react-react-app生成,并且在中期升级了react-scriptsreact等,且使用了特定版本的eslint规则。在根据报错提示进行修复后,运行项目仍然报错且无任何报错提示。

我们需要弄清楚到底是什么依赖有问题,这个过程是复杂且繁琐的,并且可能由于依赖前后版本已经发生了巨大的变化,即使找到存在问题的依赖也可能不能仅通过升级该依赖的版本解决问题。

所以一个备选方案是把项目整体迁移到新版本的create-react-app上,这里有两个做法:使用create-react-app新生成一个项目,我们把源码和依赖都迁移到新项目中,在新的项目中进行eject;使用create-react-app生成一个空的项目并eject,使用新的configscripts替换当前项目的脚本和配置,并且比较packahe.json,更新依赖的版本,重新安装依赖,大功告成。

在实际的情况下,受制于项目版本控制、团队协作、迁移成本等,第一种方式,基本不可用。

解决完问题,接下来我们需要将写在config-overrides.js中的自定义配置进行迁移。

自定义配置

在进行自定义配置之前,如果项目因eslint规则,无法运行成功,可以尝试在config/webpack.config.js中找到eslint-loader并且注释掉这个配置,此时在开发模式下,eslint并不会对源码进行规则校验。

less支持

因为create-react-app脚手架并不支持less,我们需要手动添加对less的支持。首先安装less-loader,并在webpack.config.js中的module.rules下添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const lessRegex = /\.less$/;
module: {
rules: [
...
{
test: lessRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: isEnvProduction && shouldUseSourceMap,
},
'less-loader'
),
}
]
}

注意到这里使用到了一个封装好的函数getStyleLoaders,其针对不同的css预处理器,生成适用的loader,源码非常简单:

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
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
options: shouldUseRelativeAssetPaths ? { publicPath: '../../' } : {},
},
{
loader: require.resolve('css-loader'),
options: cssOptions,
},
{
loader: require.resolve('postcss-loader'),
options: {
ident: 'postcss',
plugins: () => [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
}),
postcssNormalize(),
],
sourceMap: isEnvProduction && shouldUseSourceMap,
},
},
].filter(Boolean);
if (preProcessor) {
loaders.push({
loader: require.resolve(preProcessor),
options: {
sourceMap: isEnvProduction && shouldUseSourceMap,
},
});
}
return loaders;
};

我们知道如果使用了css预处理器,在设置loader的时候,可能需要多个loaders,例如:

  • less-loader: 把less编译成css
  • css-loader: 解决css使用importrequire引入的问题
  • style-loader: 通过style标签把css注入到DOM中。
  • 生产环境下可能还需要生成单独的css文件,css压缩等。

getStyleLoaders就是生成这些loaders的一个公共方法。

组件库按需加载

在开发过程中,如果使用到了组件库且该组件库支持按需加载(例如antd),那么我们可以根据相关的教程配置即可。

比如在当前项目中使用到了xx-ui这个组件库,该组件库支持babel-plugin-import按需加载,在安装好依赖后,我们只需要在package.jsonbabel下配置plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"babel": {
"presets": [
"react-app"
],
"plugins": [
[
"import",
{
"libraryName": "@wind/xx-ui",
"libraryDirectory": "es",
"style": true
}
],
]
}

这和传统的.babelrc中进行plugin配置无区别,但总算不用再维护额外的一个文件了。

其它

比如我们在构建时需要对代码进行拆分,我们可以在webpack.config.jsoptimization中使用splitChunks进行自定义、分离runtimeChunk等

比如我们构建时不想使用默认的static/js/[name].[contenthash:8].chunk.js作为chunks的文件名,直接修改即可。注意,在代码分割时,相应的css也会被分割,如果想修改css的配置可以直接找到MiniCssExtractPlugin进行修改即可。

最后

经过上面所有的步骤,项目已经能够完全正常运行,和eject之前一样。接下来我们可以详细阅读webpack.config.js,进行更进一步的定制,也可以删除某些项目中不需要的配置,减轻webpack.config.js的复杂性。

如果项目是老项目的话,为了项目能够在eject后不会因为eslint报错而无法正常运行,可以注释掉eslint-loader来避免每次构建时候的预检,但此时对代码风格的约束只限于编辑器本身eslint的支持,对代码质量和团队代码风格可能造成一定的影响。

更好的方式是使用一个适合这个项目的eslint规则,如eslint-config-airbnb-base,并且对这个第三方规则进行定制,直至这个规则适合这个项目。

完。

分享到 评论