Webpack自定义公共组件库按需加载优化
本文已参与「新人创作礼」活动, 一起开启掘金创作之路。
按需加载,顾名思义,意思就是比如有一段code,仅在被使用的时候才会加载,这样可以解决在访问一个页面时,一次性加载过大size的静态资源问题。按需加载,就得拆分静态资源,比如js、css。
拆分就得以打包入手,使用Webpack
打包工具、React
框架,方式一般有以下两种:
- 自定义拆分的文件,比如通过配置多个
entry
、SplitChunksPlugin
方式,多用于把第三方引用单独打成一个文件。把文件拆分了,肯定会起到作用,但很多情况是只是把一部分文件拆分出来了,但是多个页面都还得引用这个文件,这就跟没拆分没啥区别了,从引用一个大文件变成引用几个小文件,提升效果有限。 - 动态引用,利用import的promises特性懒加载,一般用在路由组件或一些比较大且比较少用的第三方包上。这个提升很大,比如用在路由上,在访问一个路由时就只会引用对应路由的js,其它路由的则不会被引用。
- 具体可以参考
Webpack code-splitting
官方文档介绍:webpack.docschina.org/guides/code…
本文在上面两种方式使用的前提下,以common自定义组件库为需求,研究其它的可行方案。
需求
一个React
项目,用Webpack
打包,有个common自定义的组件库(内部封装,不是第三方、也不是引用的npm包),由于common code体积很大,打包时会把所有common组件都打到js中,导致静态资源很大,影响首屏加载体验。
--common
--components // common组件
--title1.jsx
--button1.jsx
--button2.jsx
--button3.jsx
--button4.jsx
--less
--index.less // common 公共样式以及所有组件样式(为了方便分析问题,先不考虑样式引用)
--utils
--index.js // common工具方法
--index.js
--src
--home.jsx // 路由首页
--index.jsx // 入口文件
--page1.jsx // 路由1
--page2.jsx // 路由2
--router.jsx // 路由配置
--babel.config.js
--package.json
--webpack.config.js
// src/router.jsx
import React from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Title1 } from 'common-ui' // 引用一个title1控件
import 'common-ui/less/index.less' // 引用所有common样式
// 动态引用路由组件
const Home = ReactLazy(() => import(/* webpackChunkName: "home" */ './home'))
const Page1 = ReactLazy(() => import(/* webpackChunkName: "page1" */ './page1'))
const Page2 = ReactLazy(() => import(/* webpackChunkName: "page2" */ './page2'))
export default function RootRouter() {
return (
<BrowserRouter>
<navs.../>
<Title1 />
<Routes>
<Route exact path="/" element={<Home />} />
<Route exact path="/page1" element={<Page1 />} />
<Route exact path="/page2" element={<Page2 />} />
</Routes>
</BrowserRouter>
)
}
// 简易高价组件封装了下React.lazy
function ReactLazy(factory) {
const Lazy = React.lazy(() => factory())
return class extends React.Component {
render() {
return (
<React.Suspense fallback={null}>
<Lazy {...this.props} />
</React.Suspense>
)
}
}
}
// src/home.jsx
import { Button1, Button2, utils } from 'common-ui'
...
// src/page1.jsx
import { Button1, Button3 } from 'common-ui'
...
// src/page2.jsx
// no common import
...
// common/index.js
export { default as Title1 } from './components/title1'
export { default as Button1 } from './components/button1'
export { default as Button2 } from './components/button2'
export { default as Button3 } from './components/button3'
export { default as Button4 } from './components/button4'
export { default as utils } from './utils'
// webpack.config.js
module.exports = {
entry: {
index: './src/index.jsx',
},
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js',
chunkFilename: '[name].[chunkhash:8].js',
clean: true,
},
module: {
rules: [
... // css-loader less-loader
{
test: /\.jsx$/,
exclude: /(node_modules|bower_components)/,
use: 'babel-loader', // 具体配置参见 babel.config.js
}
],
},
resolve: {
...
alias: {
"common-ui": path.resolve(__dirname, "./common"), // 设置别名
},
},
optimization: {
minimize: false,
minimizer: [...], // 使用官方的TerserPlugin压缩js
splitChunks: {
minSize: 0, // 默认20kb左右,由于例子文件size都很小,为了模拟出来split效果,设置为0
cacheGroups: {
defaultVendors: {
chunks: 'all',
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: -10,
minSize: 0,
},
}
}
},
}
// babel.config.js
module.exports = {
"presets": [
"@babel/preset-env",
[
"@babel/preset-react", { "runtime": "automatic" }
]
]
}
利用webpack-bundle-analyzer
工具分析打包结果:
发现common的组件及工具类都打到了index.js了,index.js是直接放在html script
里的,每个页面都会引用到,如果common组件很多很大,size就很大,就会导致上述需求的问题。
分析原因
首先分析下原因,原因是import { Button1, Button2, utils } from 'common-ui'
通过这种方式引用时,babel编译器默认不会去分析大括号内的具体变量名去拆分引用(也就是tree-shaking
机制,下面会细说),它会直接根据后面的路径找文件,然后把对应文件内容都编译到输出js中。
比如此例按路径引用的是common/index.js
,里面引用了所有common组件,然后由于router.jsx
最先引用的,所以就会都打在index.js
中。
动态React router路由组件引用,比如有一些组件只在其中几个路由下引用,期望效果是就是把这些组件达到需要引用的路由js下,这样如果访问其它路由时,就不会加载这些组件,效率就会提升很多。
这里需要注意,mode: 'production'
模式下,analyzer结果是没有把button4.jsx
打进包里的,是因为production
模式下tree-shaking
机制起了一点作用,但并不会根据动态路由去拆分。
解决方案
方案1:按具体路径分开引用
如何解决此问题,最简单的方式是直接用组件的具体路径分开引用。
import { Button1, Button2, utils } from 'common-ui'
// 改为
import Button1 from 'common-ui/components/button1'
import Button2 from 'common-ui/components/button2'
import utils from 'common-ui/utils'
analyzer结果:
这才是期望的效果:
- index.js包含title1、index.less
- home.js包含button2
- page1.js包含button3
- button1由于在多处引用,独立拆分出common...button1.js
但是这样做缺点是:
- 没有
import {a,b,c}
引用的方式方便,如果一个页面引用很多组件,会写的很长,code也很多; - 得记住每个组件对应的路径及文件名;
- 如果css也要拆分,就更不方便了。
最直接原因就是不方便,写法不优雅,特别不方便使用及维护,不建议此方案。
方案2:tree-shaking
webpack.docschina.org/guides/tree…
这里用到的是Webpack自带的tree-shaking
机制,具体讲解参考上面的官方文档。按文档来看,该机制会自动解析每个import的文件,然后把unused\dead code给过滤出去,也就是不会被编译压缩到包里。
比如最上面的例子里,虽然在common\index.js
里把所有组件都export出去了,但analyzer结果是没有把button4.jsx
打进包里的,是因为production
模式下tree-shaking
机制起了一点作用,但并不会根据动态路由去拆分。
这是因为该机制把其它组件都当成有副作用,如果让tree-shaking
机制完全发挥作用,需要通过 package.json 的 "sideEffects"
属性,来实现这种方式。
// package.json
{
"sideEffects": false
}
官方文档概念比较模糊,咱们直接看结果:
似乎达到了期望效果,但仔细看可以发现,less文件没打进包里,启动页面效果里也没了样式,这是因为"sideEffects": false
会把所有类似import 'xxx'
这种引用的文件内容都当做了无副作用
,所以就被过滤掉了。官方提供了下面的方式来自定义标记副作用
的文件。
// package.json
"sideEffects": [ "*.less", "*.css" ]
也可以匹配其它路径文件,比如有个需要页面加载时,就立即执行的code,比如定义全局变量,初始化行为。见下面code,这种import方式就跟上面less文件一样了,如果不配置"sideEffects"
里面的路径,也会被打包过滤掉的。
// common/init.js
console.log('common init') // 初始化code,这里用console log举例
// src/router.jsx
import 'common-ui/init' // 在跟路由文件里引用一次,执行初始化code
还有很多case,在定义了"sideEffects"
情况下,也会被当成无副作用
的code被过滤掉。作者本人之前在一个比较大型的项目后期,尝试用此机制优化code splitting,遇到过很多文件或code被打包过滤掉了,尝试通过"sideEffects"
配置路径解决了一些之后又出现了一些问题,有些看起来是有副作用的但还是被过滤掉,最终放弃了。
总的来说这个方案就是配置简单,如果项目体量比较小,code比较规范,可以使用,Webpack官方的机制也稳定;反之如果体量比较大,这个方案坑就比较多了,可能就会产生不可预计的问题,不可控因素太多。而且此机制也会作用到业务code里,影响的不仅是common。
也可能是我的使用方式或理解不对,希望大佬指点。
方案3:babel-plugin-import
这个是ant官方为了支持ant-design相关控件库按需加载所写的babel插件,同样适用于自定义的组件库。
先看下在官方ant-design下的效果:
import { Button } from 'antd';
ReactDOM.render(<Button>xxxx</Button>);
↓ ↓ ↓ ↓ ↓ ↓
var _button = require('antd/lib/button');
require('antd/lib/button/style/css');
ReactDOM.render(<_button>xxxx</_button>
原理就是把大括号引用的方式,通过自定义babel plugin,转换成按文件具体路径的引用方式,来达到按需引用效果,也就是方案1
的效果。
然后应用到上例中,首先需要安装包npm install babel-plugin-import -D
// babel.config.js
module.exports = {
"presets": [
"@babel/preset-env",
[
"@babel/preset-react", { "runtime": "automatic" }
]
],
"plugins": [
[
"import", {
"libraryName": "common-ui",
"libraryDirectory": "components",
"style": false // 此文先不考虑样式的拆分
}
]
],
}
由于是转换了路径,引用utils时会按路径找/common/components/utils.js
文件,所以还需要为utils加个文件:
// common/components/utils.js
import utils from '../utils'
export default utils
期望效果如下:
import { Button1, Button2, utils } from 'common-ui'
↓ ↓ ↓ ↓ ↓ ↓
import Button1 from 'common-ui/components/button1'
import Button2 from 'common-ui/components/button2'
import utils from 'common-ui/components/utils'
analyzer结果:
达到了期望效果,babel-plugin-import插件还支持别的属性功能,比如:
"import", {
"libraryName": "common-ui",
"libraryDirectory": "components",
"customName": (name, file) => {
// const filename = file.opts.filename
if (name === 'utils') {
return 'common-ui/utils/index'
}
return `common-ui/components/${name}`
},
"style": false
}
自定义name转换路径,这样就不用加/common/components/utils.js
文件进行转换了。
对于立即执行的code,因为没有export,然后这种case一般是global的,不会太频繁引用,所以可以直接用路径引用:import 'common-ui/init'
有一个缺点是,import {name1,name2}
每个大括号内的name必须对应common/components
里的一个文件、或者用customName
自定义路径文件,总之必须是一个文件,对于那种一个文件export多个组件的结构,就得分别加文件起到路径转换效果,比如下面例子:
// common/components/grid.jsx 一个文件里export了俩个组件,由于是相近功能,写在一个文件里方便维护
export function Row({ children }) {
return (
<div className="common-row">{children}</div>
)
}
export function Col({ children }) {
return (
<div className="common-col">{children}</div>
)
}
如果按照方案1
中的直接按路径引用,得这么写:
// src/home.jsx
import { Col, Row } from 'common-ui/components/grid'
<Row>
<Col>col1</Col>
<Col>col2</Col>
</Row>
但是若想按方案3
,得在components
路径下分别创建两个对应export文件:
// common/components/row.js
import { Row } from './grid'
export default Row
// common/components/col.js
import { Col } from './grid'
export default Col
然后才能用下面方式来import:
// src/home.jsx
import { Button1, Button2, Col, Row, utils } from 'common-ui'
也就是说,import { a,b,c } from 'common-ui'
这里的a,b,c
,只能是对应转换路径一个文件下的export default
,a,b,c
就得对应三个文件里的export default
。当然,也可以按照下面方式写,但是有点违背开发规范,需要多写一行,也不好维护。
// common/components/grid.jsx
export default { Row, Col }
// src/home.jsx
import { Button1, Button2, Grid, utils } from 'common-ui'
const { Col, Row } = Grid
// or
<Grid.Row>..</Grid.Row>
babel-plugin-import还有很多属性用法,具体参考官方文档。另外还有个饿了么团队做的插件 babel-plugin-component,跟babel-plugin-import差不多,差异见 链接
只要把业务需要的组件或公共类以对应单个文件方式创建在common/components
路径下就行,个别可以通过customName
转换,然后让各业务开发严格按照大括号扩展方式引用(即import { a,b,c } from 'common-ui'
),除了部分特殊需求(比如在跟路由文件里引用common less),其它业务禁止直接通过路径common-ui/xx
方式引用。
缺点:
- 按上面说的,需要在
common/components
路径下创建文件,如果组件很多,文件也会很多,但毕竟是common的code,也方便维护,也不会需要各业务大量改动的情况。 - 对编译器的智能感知不太友好,比如如下配置了
jsconfig.json
,对于import { a,b,c } from 'common-ui'
这种写法不好用,编译器无法智能感应。
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"common-ui/*": ["./common/*"], // 支持直接写路径那种,比如 import 'common-ui/init'
"common-ui": ["./common/components/*"] // 不支持,不好用
}
}
}
最佳方案
综合来看,方案3是最佳实现:
- 相比方案1,业务代码里import写法更方便,更优雅,更方便维护。
- 相比方案2,这个方案更可控些,不用调试和控制代码的
副作用
,作用域仅限于对common-ui
的引用,也不用担心production
模式下与的差异。
源码已上传GitHub:github.com/markz-demo/…
当前使用了方案3,如果想尝试另外两个方案,可以自行修改code查看效果。
总结
掘金平台第一篇文章,也是人生中第一篇技术博客吧,正好最近在优化公司一个项目前端,就把研究的结果记录分享出来,虽然最后都由于项目限制都给否了,希望能用在下一个项目里吧。也希望我能坚持下去写博客。
转载自:https://juejin.cn/post/7194371236414914617