从@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,并且对这个第三方规则进行定制,直至这个规则适合这个项目。

完。

分享到 评论

老项目Webpack构建优化

最近在接手一个项目的时候,由于其开发已经一年有余,整个项目使用了create-react-app脚手架,Webpack版本停留在3.x,React版本15.x,并且在线上使用效果并不好,首页加载缓慢。对于这样一个典型的老项目,进行构建优化。

webpack 3.x之前的构建中,该项目使用CommonChunkPlugin对特定的package进行了抽取,例如react、图表库、组件库,其余使用到的package和业务代码被打包在了一起,Gzip过后差不多1MB。通过分析可以得知,分离出的第三方库和业务代码存在重复打包的情况,并且页面初始加载并不需要用到图标库,而组件库只使用到了部分组件却被完全打包了。

升级Webpack

由于使用的是create-react-app脚手架,其自带了react-scripts作为webpack的配置,本项目使用的是老版本脚手架生成,Webpack版本是3.x。分析后,在不更新脚手架的前提下,直接升级Webpack,可以使用splitChunk进行代码分隔,配合新版本的Reactlazy & Suspense,可不引入三方库的情况下实现按需加载。

升级Webpack比较容易,直接升级最新版的react-scripts即可(注意不是react-script)。

1
yarn add --exact react-scripts@2.1.1

安装完后yarn start运行项目,不出意外会有报错提示,因为某些react-scripts依赖的库版本过低,此时只要删除yarn.lockpackage-lock.jsonnode_modules,并且运行yarn install重新安装依赖即可。

安装完成后,不出意外项目可以正常运行了,此时Webpack已升级。

代码分隔

  1. 考虑到初次构建的时候仅仅分离部分第三方库导致第三方包和业务代码之间存在代码冗余,在新构建的版本中,需要对公用代码进行抽取,这样可以直接避免冗余。具体的splitChunk配置这里不再列出,抽取完成后例如lodashmoment这样的公共库被抽取到了公共包中,构建完成后生成了三个包:业务代码及未抽取的第三方包、特定的第三方包、公共代码包,构建体积明显减少。

  2. 由于业务的特殊性,首页并不包含任何图表,因此不需要加载图表库,并且图表库打包完成后大小为700KB(未Gzip),因此考虑使用基于路由的按需加载来去除首页不需要加载的资源。

  3. 进行代码分隔依赖于运行时加载,也就是import(),这是一个ES提案,目前还未标准化(babel已实现)。不同于import语法在预编译时链接代码,import()可以在运行时异步加载资源。而Webpack在解析到这种语法的时候,就会自动的进行代码分隔。

  4. 目前React懒加载的方案有很多,比如react-loadable,但由于新版的React已经支持了代码分隔,就没必要引入额外的库。React.lazy函数能够让你像处理常规组件一样处理懒加载组件。该函数接收一个函数作为参数,这个函数必须使用到import()动态加载组件,import()返回一个Promise,在resolve后返回这个React组件。Suspense是一个组件,其主要的作用是在懒加载块没有加载完成时使用占位组件进行UI 的优雅降级。

  5. 经过分析,基于路由的分隔可能最适合本项目。涉及到图表库的三个页面使用懒加载进行代码分隔,其余页面打包在一起,并且完全分离第三方库和业务代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import { lazy, Suspense } from 'react';
    import { render } from 'react-dom';
    import Loading from '@/components/loading'
    const ChartPage = lazy(() => import(/* webpackChunkName: "detail" */'@/views/chartPage'));
    const App = () => (
    <Router>
    <Suspense fallback={Loading}>
    <Switch>
    <Route path='/detail' component={ChartPage} />
    ...
    </Switch>
    </Suspense>
    </Router>
    );

直接运行yarn start,观察打包结果,发现已经按照路由进行了分隔,在总打包大小基本不变的情况下,初始加载的包只有230KB,减少了一大半。

  1. 观察打包结果,发现公共包的命名是0.hash.chunk.js,对于已经在注释中指定了webpackChunkName,这显然是不正常的,可能是react-scripts默认分隔策略的问题。为了修复这个命名问题并且进一步分隔runtime chunk,对splitChunk进行少量的配置。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const SplitCodeConfig = {
    optimization: {
    splitChunks: {
    chunks: 'all',
    },
    runtimeChunk: {
    name: 'runtime'
    }
    }
    }

只需要进行简单的配置,设置splitChunkschunksall, Webpack就会使用默认的规则进行打包:抽取公共包,抽取node_modules第三方包等等,具体见Webpack官网 -> 文档 -> 代码分离

至此,项目构建已经完成,构建的包可以直接用于生产换环境。

思考

  1. 在项目中使用到的组件库包含近百个通用组件和业务组件,但是项目中只使用到了十余个,并且通过观察打包结果,可以看到未被使用到的组件也被打包进来了,这显然不合理。针对这个问题,我们可以查看对应组件库的按需加载方案,并且在Webpack进行相应的配置即可。这里由于使用的是内部开发的组件库,就不在举例。

  2. 除了使用的组件库,我们需要找到所有能够按需加载却在项目中没有使用的第三方库。例如我要使用lodashdebounce函数进行防抖,我在项目中是这样使用的:

    1
    2
    import _ from 'lodash';
    window.onscroll = _.debounce(handler);

在构建的时候,整个lodash会被打包,如果只需要打包debounce函数,在import的时候直接import debounce from 'lodash/debounce'即可。

其它

  1. 为了直观的查看构建结果,推荐使用webpack-bundle-analyzer插件,它会在构建完成后打开一个页面,该页面可以看到每个bundle的打包情况。使用也很简单:

    1
    2
    3
    4
    5
    6
    7
    const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
    {
    ...
    plugins: [
    new BundleAnalyzerPlugin(),
    ]
    }
  2. 如果想测试Gzip后的文件大小,在webpack-dev-server的配置项中添加compress: true即可。但如果查看生产环境下构建的Gzip包大小,可以使用compression-webpack-plugin插件,该插件会在构建完成后生成对应bundleGzip包。

    1
    2
    3
    4
    5
    6
    7
    const CompressionPlugin = require('compression-webpack-plugin');
    {
    ...
    plugins: [
    new CompressionPlugin({ threshold: 8192 }), // 只有大于8KB的资源才压缩
    ]
    }

在一般的情况下开启Gzip只需要设置生产服务器即可,并不需要在构建时生成Gzip包,但如果每次请求时再Gzip无疑会加大服务器的负担,因此事先准备好gzip包,服务器不必压缩而是直接返回,这不失为一个好的选择。

开启Gzip会极大的减小传输体积,但无论是压缩还是解压都需要大量的运算,对于某些较小的资源,使用Gzip可能反而会降低性能,因此建议只对较大的资源进行Gzip压缩,而较小的资源直接传输。

完。

分享到 评论

Webpack 4.x 简易使用指南

本篇来自于之前在团队内部分享webpack时的笔记,内容比较浅显,主要讲webpack基本配置,同时webpack运行原理,自定义loaderplugin也有涉及。

Webpack4.x使用指南

零配置使用Webpack

从Webpack4开始支持零配置,在不进行任何配置的形式下,直接运行webpackwebpacksrc/index.js作为入口,最终结果输出到/dist

在默认情况下,webpack只会对只会从入口开始遍历所有依赖文件,由于没有配置loaders,因此无法处理jsx/css/图片等。

在默认情况下运行webpack,打包完成后控制台会提示在配置打包的模式。在cli中通过设置--mode=production|development可以指定默认,在开发模式|产品模式下,webpack会默认启用不同插件对打包进行优化。

webpack基本配置

  1. 入口(entry)
    入口的配置形式有多种,例如:

    1
    2
    3
    4
    5
    6
    entry: './src/index.js', // #1
    entry: {
    home: './home.js',
    about: './about.js',
    }, // #2
    entry: ['./home.js', './about.js'] // #3

    第一种方式配置单一入口,所有文件将会被打包到同一个文件中,例如默认将会打包到main.js中;第二种方式是多页面(MPA)的配置方式,配置多个入口,最终将会生成多个bundle,配合html-webpack-plugin可以将对应的bundle注入到对应的html中;第三种配置的形式指定了多个入口,但是会将最终结果打包到一个文件中。

  2. 输出(output)
    输出指定了webpack将结果输出到哪里,如何进行输出,甚至是使用何种方式构建等,配置比较复杂。常用配置举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    output: {
    filename: '[name].[hash].js',
    path: path.resolve(__dirname, 'dist'),
    }, // #1
    output: {
    filename: '[name].[hash].js',
    path: path.resolve(__dirname, 'dist'),
    library: 'xxLibrary',
    libraryTarget: 'amd',
    }, // #2

    第一种配置方式是最基本的输出配置,它配置了输入文件的存放的目录以及文件命名,filename支持模板语法,[name].[hash].js表示使用模块名称(入口配置)以及模块标识符来命名文件。
    第二种配置增加了’library’, ‘libraryTarget’两个属性,而library值的作用取决于libraryTarget的值。例2中配置libraryTarget: 'amd'表示使用amd的方式打包,而library的值将作为模块名。

    1
    2
    3
    define('xxLibrary', [], function() {
    return _entry_return_;
    })

    而当libraryTarget: 'commonjs2'时,表示该模块将使用commonjs的方式打包,用于node环境,而library将不起作用。此外libraryTarget的值还可以是var(默认)this等。

  3. 加载器(loader)
    在webpack中module决定了如何处理项目中不同类型的模块,而加载器(loader)则是具体的处理逻辑。

    noParse:指定不需要进行构建的文件。例如,指定项目中不需要对jquery进行构建,可以进行如下配置:

    1
    2
    3
    noParse: function(content) {
    return /jquery/.test(content);
    },

    规则(rules)的配置同样比较复杂,常用的配置如下:

    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
    rules: [
    {
    test: /\.(js|jsx)$/,
    exclude: /(node_modules|bower_components)/,
    include: /src/,
    use: 'babel-loader'
    }, // #1
    {
    test: /\.(png|jpeg|gif)$/,
    oneOf: [
    {
    resourceQuery: /inline/,
    use: 'url-loader',
    },
    {
    resourceQuery: /external/,
    use: 'file-loader',
    }
    ]
    }, // #2
    {
    test: /\.css$/,
    use: ExtractTextPlugin.extract({
    fallback: 'style-loader',
    use: [
    {
    loader: 'css-loader',
    options: {
    modules: true
    },
    },
    ],
    }),
    },
    ], // #3

    上面列举了常用的配置loader的三种情况,第一种处理js|jsx文件时,使用excludeinclude指定构建目录和不需要构建的目录,对于明确不需要构建的模块进行指定可以加快构建速度。第二种情况则是根据具体情况选择对应的loader进行处理。例如当我们使用import emoj from './emoj.png?inline'时,则会使用url-loader对该png文件进行处理,对于external类型的图片,则使用file-loader处理。第三种情况则是使用多个loader并且可以传递options,例如处理css文件,这里使用了style-loadercss-loader两个加载器,并且配置了css-loader的optionsmodules: true。这里需要关注loader执行顺序,loader执行的顺序是先配置后执行,例如这里先使用css-loader解析css,再使用style-loader配置将生成好的css文件通过<style>标签注入到DOM中。options配置了modules: true,则开启css-modules,写法如下:

    1
    2
    3
    4
    import style from 'index.css';
    let IButton = ({children}) => (
    <span className={style.btn}>{children}</span>
    );
  4. 插件(plugin)
    插件(plugin)用于以各种方式自定义webpack构建过程。webpack附带了非常多的插件,可以直接使用webpack.pluginname使用,也存在非常多的第三方插件。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    plugins: [
    new CleanWebpackPlugin(['dist']), // #1
    new HtmlWebpackPlugin({
    title: 'index',
    chunks: ['index'],
    }), // #2
    new BundleAnalyzerPlugin({
    analyzerMode: 'server',
    analyzerHost: '127.0.0.1',
    analyzerPort: 8889,
    }), // #3
    ],

上例使用了三个插件,第一个插件CleanWebpackPlugin作用是在构建输出前清空文件夹;第二个插件用于生成入口html,例如该htmltitle,指定其需要注入的bundle(MPA)。第三个插件则是用于分析构建结果的插件,它会在本地启动一个开发服务器,并且通过canvas来绘制构建可视化结果,方便进行构建分析和优化。

  1. 开发服务器(devServer)
    webpack-dev-server方便我们进行快速开发应用,它会对代码进行监控,一旦发生更改,它会立即构建,并通过补丁的方式应用更改,使得应用能够快速应用更改。并且其提供了一个基于Express开发的简易服务器,所有资源文件都存在内存中,访问速度极快,并且通过配置可以支持热替换。介绍一下常见的webpack-dev-server配置。
    1
    2
    3
    4
    5
    6
    7
    devServer: {
    contentBase: path.join(__dirname, 'dist'),
    compress: true,
    inline: true,
    historyApiFallback: true,
    allowedHosts: ['host1.com', 'host2.com'],
    }

contentBase主要用于指定静态文件的存放路径,例如所需要图片等,如不指定则无法找到对应文件;

compress表示是否开启Gzip压缩;

inline表示是否启用内联模式,内联模式:实时重载的脚本会被插入到bundle中,构建消息将会出现在控制台。此外还有iframe模式。

historyApiFallback主要针对的是访问不存在的页面时的活动,historyApiFallback: true时访问不存在的页面会直接跳转到index.html,也可以传入一个对象更精确的进行控制跳转。

allowedHosts用于指定允许该devServer的主机。

hot是否开启热替换,如果为true的话,则会使用webpack.HotModuleReplacementPlugin插件。通过CLI的方式传递。

1
webpack-dev-server --hot

Webpack 运行流程

概括上来说:
初始化配置参数 -> 绑定事件钩子回调 -> 确定Entry开始遍历 -> 使用loader编译文件 -> 输出

webpack就像一条生产线,经过一系列处理流程后才能将源文件转换成输出结果。

每个流程的处理职责是单一的,流程之间存在依赖关系,只有当前处理完成后才能交由下一个流程处理。

插件像是插入到生产线上的一个功能,在特定的时机对资源进行处理。

webpack 通过 Tapable来组织这一切。

Webpack在运行过程中会广播事件,插件只监听它关心的事件。

—《深入浅出webpack》

Tapable的核心代码可以简化成。

1
2
3
4
5
6
7
8
9
10
11
12
13
class SyncHook {
constructor() {
this.hooks = [];
}
// 订阅事件
tap(name, func) {
this.hook.push(func);
}
// 发布
call() {
this.hooks.forEach(hook => hook(...arguments));
}
}

Webpack具体的流程如图所示。
流程

  1. 初始化构建参数(依据webpack.config.js),插件实例化,生成Compiler供插件使用,挂载自定义钩子。

  2. 依据入口递归遍历文件,并且使用对应的loader进行编译。

  3. 将编译好的文件解析成AST(抽象语法树),分析依赖逐个拉取济源。

  4. 编译完成,输出。

编写自定义loader

在webpack中,真正起编译作用的就是各种各样的loader。loader其实是一个function,其传入匹配到的文件内容(String),然后对这些内容做处理即可。

一个最简单的loader可以使下面这样:

1
2
3
module.exports = function(content) {
return "{};" + content;
}

config中进行配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
module: {
rules: [
{
test: /\.js$/,
loader: path.resolve(__dirname, './loaders/index.js'),
options: {
param1: 1,
param2: 2,
}
}
]
}

这样在编译JS的时候就会在每个JS文件前加上{};


获取自定义配置

可以使用loader-utils模块来拿到自定义配置。

1
2
3
4
5
6
7
const loaderUtils = require('loader-utils');
module.exports = function(content) {
let options = loaderUtils.getOptions(this);
console.log(options.param1); // 1
console.log(options.param2); // 2
return "{};" + content;
}


数据导出

loader可以通过return来返回处理后的结果;当然,更好的方式是使用this.callback的形式。因为它更加灵活,除了content以外,还可以传递其它参数。

this.callback(error, content, sourceMap, ast)可以传入四个参数:

  • error loader向外抛出一个错误
  • content 经过loader编译后的内容
  • sourceMap
  • ast 本次编译生成的AST,之后执行的loader可以直接使用,而不需要再次生成
1
2
3
4
5
6
7
const loaderUtils = require('loader-utils');
module.exports = function(content) {
let options = loaderUtils.getOptions(this);
console.log(options.param1); // 1
console.log(options.param2); // 2
this.callback(null, "{};" + content);
}

异步loader
对于异步loader,可以使用this.async来获取
callback函数。

1
2
3
4
5
6
7
8
9
10
11
const loaderUtils = require('loader-utils');
module.exports = function(content) {
let options = loaderUtils.getOptions(this);
let callback = this.async();
this.cacheable(false); // 是否缓存结果
console.log(options.param1); // 1
console.log(options.param2); // 2
setTimeout(function() {
callback(null, "{};" + content);
}, 1000);
}

pitch钩子
可以在loader文件中exports一个名为pitch的函数,它会先于所有的loaders执行。可以在这个过程中传参,而当前rule的所有loaders都可以拿到这个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const loaderUtils = require('loader-utils');
module.exports = function(content) {
let options = loaderUtils.getOptions(this);
let callback = this.async();
this.cacheable(false); // 是否缓存结果
console.log(options.param1); // 1
console.log(options.param2); // 2
console.log(this.data.hello); // hello
setTimeout(function() {
callback(null, "{};" + content);
}, 1000);
}
module.exports.pitch = function(remaining, preceding, data) {
data.hello = 'hello';
}

Plugin 初探

Plugin起始是一个简单的class,有一个必须要实现的apply方法。

1
2
3
4
5
6
7
8
9
class LPlugin {
constructor(options) {
this.options = options;
console.log('options', options);
}
apply(compiler) {
console.log('run this plugin');
}
}

使用

1
2
3
4
5
6
7
8
9
10
const LPlugin = require('./plugins/LPlugin');
module.exports = {
...,
plugins: [
new LPlugin({
param1: 1,
param2: 2,
})
]
}

plugin在初始化参数就进行实例化(事件流开始时),因此类似于CleanWebpackPlugin在构建之前进行文件操作删除掉某些目录也是很好实现的。


Tapable & Hook Tapable是Webpack构建过程中的核心所在,其暴露了tap tapAsync tapPromoise等方法,可以使用这些方法,来注入一些逻辑,这些逻辑将会在构建过程的不同时机触发。

例如:

1
2
3
compiler.hooks.compile.tap('LPlugin', params => {
console.log('触发了钩子函数');
})

该钩子函数将会在compile阶段触发。

*** 自定义钩子函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { SyncHook } = require('tapable');
class LPlugin {
constructor(options) {
this.options = options;
console.log('options', options);
}
apply(compiler) {
compiler.hooks.LPlugin = new SyncHook(['data']);
compiler.hooks.enviroment.tap('MyPlugin', function() {
compiler.hooks.LPlugin.call('hello');
})
console.log('run this plugin');
}
}

1
2
3
4
5
6
7
class LLPlugin {
apply(compiler) {
compiler.hooks.LPlugin.tap('LLPlugin', function(data) {
console.log('data', data); // data hello
});
}
}
分享到 评论

使用Webpack DllPlugin

DllPlugin 简易使用指南

  1. 创建webpack.dll.config.js 用于对特定的模块打包成dll
  2. webpack --config webpack.dll.config.js 生成dll以及其描述文件
  3. webpack.common.config.js中使用DllReferencePlugin引入打包好的dll文件。
  4. 打包。此时遇到相应的模块时直接引入而不会重新打包。

创建webpack.dll.config.js

这里以分别打包momentlodash为例

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
// config.js
module.exports = {
entry: {
lodash: ['lodash'],
moment: ['moment'],
}
}
// webpack.dll.config.js
const webpack = require('webpack');
const path = require('path');
const config = require('./config');
module.exports = {
entry: config.entry,
output: {
path: path.resolve(__dirname, '/static/dll'),
filename: '[name].dll.js',
library: '[name]_library'
},
plugins: [
new webpack.DllPlugin({
path: path.join(__dirname, '/static/dll', '[name]-manifest.json'),
name: '[name]_library',
context: __dirname,
})
]
}

解释:

  1. 推荐把入口配置信息写在单独文件中,易于维护。
  2. DllPlugin类接受一个配置对象,该对象有三个属性:context(绝对路径), manifest文件中请求上下文;name,暴露的dll函数名;path:manifest文件存放的位置(绝对路径)。

使用DllReferencePlugin

1
2
3
4
5
6
7
8
9
10
11
12
// webpack.common.config.js
const config = require('./config');

module.exports = {
... // 省略其它配置
plugins: [
...Object.keys(dllConfig.entry).map((name) => new new webpack.DllReferencePlugin({
context: __dirname,
manifest: require(`./static/dll/${name}-manifest.json`),
}))
]
}

DllReferencePlugin 接受一个对象用于初始化

  1. context: manifest的上下文(绝对路径),需和DllPlugin中的context一致
  2. manifest: manifest文件,使用require引入或指定绝对路径

可选参数:

  1. content:模块id的映射,默认为 manifest.content
  2. name: dll文件的名称,默认为 manifest.name
  3. scope: dll 内容前缀
  4. sourceType: dll如何暴露的?amd commonjs2 …

scope: ‘abc’, 则该dll中的xyz文件可以通过require(‘abc/xyz’)来引用
例如在一个dll中打包了lodashaxios两个库,并且指定了{scope: 'lib'},则在需要使用axios的时候使用require('lib/axios')即可。

测试1:把echartswinduireact及其周边分别抽取成dll文件。

抽取的dll文件大小分别为:753KB 1603KB,385KB。

构建方式 平均构建时间 包大小
普通 97s 4.1MB
使用Dll 71s 2.6MB
dll文件 24s 2.7MB

经过对比发现,由于无法使用按需加载,所以整个windui打包的大小差不多为1.6MB,而普通的打包方式windui的大小仅为365KB。

在使用windui链接库后生成的打包,发现vendors模块中仍然含有windui,大小为330KB,仅windui中的node_modules文件夹下的rc-triggerrc-dropdown被重用,因此这里可能重复打包了。

windui中依赖的rc-*部分模块仍然被打包。

方式二 仅对react相关模块和echarts进行打包

抽取的两个dll文件的大小为393KB 769KB

构建方式 平均构建时间 包大小
普通 97s 4.1MB
使用DLL 77s 3.08MB
dll 11s 1.16MB

windui的大小基本不变,而windui依赖的rc-*系列组件基本没被打包。

vendors模块从0.61MB1.67MB

使用html-webpack-include-assets-plugin 把dll注入到index.html

1
2
3
4
5
const HtmlWebpackIncludeAssetsPlugin = require('html-webpack-include-assets-plugin');
const dllIntoHtml = new HtmlWebpackIncludeAssetsPlugin({
assets: ['./static/dll/echarts.dll.js', './static/dll/react.dll.js'], // 需要注入的dll文件路径
append: false, // 是否尾注入?push : unshift
});

因为先构建dll,再进行项目构建,在项目构建过程中会删除整个build目录,所以在构建完后再把dll文件夹拷贝进build/static/

总结

  1. windui进行dll打包后会因为无法按需加载而导致总大小偏高(且可能会产生冗余);而不进行的话,包的总大小基本不变。

  2. 初始时需要加载的chunk从3个变为6个/5个

升级到webpack4.x

升级到webpak4.x后无论是dll还是最终生成块,都有小幅度的下降。与此同时,构建总时间(dll构建时间+项目build时间)略微减小。

关于使用已存在的模块直接作为dll文件引入的可能性

  1. 目前无相关方面的实践。
  2. DllPlugin抽取特定的模块构建dll文件后会生成一个manifest文件。该存储了各个模块的和公共模块的对应关系。
    该文件会对已经打包成dll的模块中的文件进行描述,会给每个文件指定id,并且该json文件中的name属性对应dll的library
  3. 在进行项目构建的时候,如需要打包某个模块,会在manifest文件中查找,如果该模块已经存在于dll中,依据manifest中的信息进行链接即可,不必重新打包。

构建的dll块和使用splitChunk或者直接使用babel打包出来的块不一致。其依赖构建dll时指定的library等,而manifest文件也是和该块一一对应的。所以从理论上,使用其它方式提供dll文件由于构建方式和无法提供manifest文件,在构建过程中并不能被重用。

PS: 本片文章来自于我在团队内部分享的笔记,任何关于webpack-dll-plugin的理解和使用方式请在该插件的Webpack介绍页查看

分享到 评论

前端路由拦截和http响应拦截

问题由来

最近在制作毕业设计的时候,遇到一个问题,那就是用户的访问控制。简单点来说,就是未登录用户只能访问某些特定的页面、API。最初我的想法是用户登录后返回一个凭证,用户以后的每次http请求都带上该凭证,进行验证,只有验证成功才能继续请求。然后在每个页面进行判断,如果用户是未登录或者凭证失效,则进行相应的提示和路由跳转。刚开始的时候,这个方法是完全可行的,但是在开发过程中,随着业务逻辑变得复杂、页面增多,重复代码太多,这样的方式也许并不合适。

如何解决

首先是后端,我使用了jsonwebtoken,用户登录成功都会生成一个具有一定时效的 token,这个token会发回到客户端,并且接下来每次发起http请求,都在http头的authorization字段带上这个token。我这里使用了axios这个http请求库,只需要在拿到token后:

1
axios.defaults.headers.common.authorization = `Bearer ${token}`;

就可以了。

由于在开发过程中涉及到跨域,这里我使用CORS来解决:通过设置一系列Access-Control-Allow-*响应头进行访问控制,上面提到了在请求头的authorization字段中设置token,因此发出的请求都不是简单请求,所以注意在每次发起http请求时,就会自动发起一个OPTIONS请求。

我服务器端用的是Express框架,我们需要写一个中间件来处理每一个请求。处理逻辑为:针对每个OPTIONS请求,直接放行;对于某些请求,如果在白名单中(例如登录、注册等不需要验证的路由),放行;对于其他请求,我们拿到其携带的token,并且进行验证,如果验证通过,放行,否则结束请求,返回未授权。具体的代码如下,这里我使用jsonwebtoken这个package,用于生成token和进行token验证。

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
app.use((req, res, next) => {
console.log('methods' ,req.method);
if( req.method === 'OPTIONS' ) {
console.log('option请求直接通过');
next();
}else {
// 除去某些特定的API,其余的都做token的验证
let { path } = req;
if(path === '/api/users/auth'
|| path === '/api/users/auth_vc'
|| path === '/api/users/check_id_validation'
|| path === '/api/users/regist'
|| path === '/api/users/send_reset_email'
|| path === '/api/users/reset_password'
)
{
console.log('本次请求不需要验证权限');
next();
}else {
const token = req.headers.authorization ? req.headers.authorization.split(' ')[1] : '';
req.token = token;
jwt.verify(token, KEY, (err, decoded) => {
if(err) {
res.status(401).json({ status: 3, error: '用户认证失败', data: '' })
}else {
console.log('验证权限通过');
req.decoded = decoded;
next();
}
})
}
}
})

然后是前端,我想如果能像后端拦截每个请求一样,写一个逻辑拦截所有的相应,并进行处理,信号,axios自带拦截器,我们只需要写我们的逻辑就可以了。我的想法是,拦截每一个相应,如果其状态码是401,那么久提示token失效,并且进行路由跳转。
vue-cli构建的应用为例,在main.js中,下面是实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
axios.interceptors.response.use(data => data, (error) => {
if(error.response) {
switch(error.response.status) {
case 401: {
localStorage.removeItem('token');
router.replace({
path: '/auth',
query: {redirect: router.currentRoute.fullPath}
})
}
}
}
return Promise.reject(error);
})

值得说明的是,如果我们在某个访问的过程中,token失效,我们需要跳转到登录页面,但是想登录过后再跳转回来,所以这里在进行路由跳转的时候,我设置了一个参数, redirect,表示传入当前的路径,当我们登录成功后,在跳转回来即可。

最后是路由拦截,这里我使用了vue-router,其实vue-router的路由对象提供一个钩子函数beforeEach,其会在每一次路由跳转之前,执行这个函数,我们就在这里进行路由拦截。原理很简单,使用一个标志位标明每个路由是否需要用户权限,如果需要的话,我们检查保存在本地的凭证,一般存在localStorage中,如果不含凭证就直接跳转到登录页面。

好了,找到根路由文件,添加:

router.beforeEach((to, from , next) => {
  if(to.matched.some(res => res.meta.requireAuth)) {
    if(localStorage.getItem('token')) {
      next();
    }else {
      next({
        path: '/auth',
        query: { redirect: to.fullPath }
      })
    }
  }else {
    next();
  }
})

这里要注意的是,res.meta.requireAuth是你自己在声明路由的时候自定义的。

总结

差不多,这算是一个比较好的解决方案了。但是有这样一个情况:如果用户凭证有效期是1小时,那么如果我浏览网页超过一个小时了,凭证还是保存在本地的,当我们进行路由跳转的时候,并没有验证凭证是否失效,所以还是会进行路由跳转。这里不用担心,因为进入进入了一个路由后,一旦发起http请求,token失效,http相应拦截就会生效,进而跳转到登录页面。

加油!

分享到 评论