likes
comments
collection
share

Webpack自定义公共组件库按需加载优化

作者站长头像
站长
· 阅读数 41

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

按需加载,顾名思义,意思就是比如有一段code,仅在被使用的时候才会加载,这样可以解决在访问一个页面时,一次性加载过大size的静态资源问题。按需加载,就得拆分静态资源,比如js、css。

拆分就得以打包入手,使用Webpack打包工具、React框架,方式一般有以下两种:

  1. 自定义拆分的文件,比如通过配置多个entrySplitChunksPlugin方式,多用于把第三方引用单独打成一个文件。把文件拆分了,肯定会起到作用,但很多情况是只是把一部分文件拆分出来了,但是多个页面都还得引用这个文件,这就跟没拆分没啥区别了,从引用一个大文件变成引用几个小文件,提升效果有限。
  2. 动态引用,利用import的promises特性懒加载,一般用在路由组件或一些比较大且比较少用的第三方包上。这个提升很大,比如用在路由上,在访问一个路由时就只会引用对应路由的js,其它路由的则不会被引用。
  3. 具体可以参考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工具分析打包结果: Webpack自定义公共组件库按需加载优化

发现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结果: Webpack自定义公共组件库按需加载优化

这才是期望的效果:

  • index.js包含title1、index.less
  • home.js包含button2
  • page1.js包含button3
  • button1由于在多处引用,独立拆分出common...button1.js

但是这样做缺点是:

  1. 没有import {a,b,c}引用的方式方便,如果一个页面引用很多组件,会写的很长,code也很多;
  2. 得记住每个组件对应的路径及文件名;
  3. 如果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
}

官方文档概念比较模糊,咱们直接看结果: Webpack自定义公共组件库按需加载优化

似乎达到了期望效果,但仔细看可以发现,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

npmjs.com/package/bab…

这个是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结果: Webpack自定义公共组件库按需加载优化

达到了期望效果,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 defaulta,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
评论
请登录