乐趣区

从零开始手写一个优化版的React脚手架Webpack最新版配置

webpack马上要出 5 了,完全手写一个优化后的脚手架是不可或缺的技能。

  • 本文书写时间 2019 年 5 月 9 日 , webpack 版本 4.30.0最新版本
  • 要转载必须联系本人经过同意才可转载 谢谢!
  • 杜绝 5 分钟 的技术,我们先深入原理再写配置,那会简单很多。
  • 实现需求:

    • 识别 JSX 文件
    • tree shaking 摇树优化 删除掉无用代码
    • PWA功能,热刷新,安装后立即接管浏览器 离线后仍让可以访问网站 还可以在手机上添加网站到桌面使用
    • CSS模块化,不怕命名冲突
    • 小图片的 base64 处理
    • 文件后缀省掉 jsx js json
    • 实现懒加载,按需加载,代码分割
    • 支持 less sass stylus 等预处理
    • code spliting 优化首屏加载时间 不让一个文件体积过大
    • 提取公共代码,打包成一个chunk
    • 每个 chunk 有对应的chunkhash, 每个文件有对应的contenthash, 方便浏览器区别缓存
    • 图片压缩
    • CSS压缩
    • 增加 CSS 前缀 兼容各种浏览器
    • 对于各种不同文件打包输出指定文件夹下
    • 缓存 babel 的编译结果,加快编译速度
    • 每个入口文件,对应一个chunk,打包出来后对应一个文件 也是code spliting
    • 删除 HTML 文件的注释等无用内容
    • 每次编译删除旧的打包代码
    • CSS 文件单独抽取出来
    • 等等 ….
  • webpack中文官网的标语是:让一切都变得简单

  • 概念:

    • 本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle
    • webpack v4.0.0 开始,可以不用引入一个配置文件。然而,webpack 仍然还是高度可配置的。在开始前你需要先理解四个核心概念:

      * 入口(`entry`)
      * 输出(`output`)
      * `loader`
      * 插件(`plugins`)
    ` 本文旨在给出这些概念的高度概述,同时提供具体概念的详尽相关用例。`
 

让我们一起来复习一下最基础的 Webpack 知识,如果你是高手,那么请直接忽略这些往下看吧 ….

  • 入口

    • 入口起点 `(entry point)指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。
    • 每个依赖项随即被处理,最后输出到称之为 bundles 的文件中,我们将在下一章节详细讨论这个过程。
    • 可以通过在 webpack 配置中配置 entry 属性,来指定一个入口起点(或多个入口起点)。默认值为 ./src
    • 接下来我们看一个 entry 配置的最简单例子:

      webpack.config.js
      
      module.exports = {entry: './path/to/my/entry/file.js'};
    • 入口可以是一个对象,也可以是一个纯数组

      entry: {app: ['./src/index.js', './src/index.html'],
          vendor: ['react'] 
      },
      entry: ['./src/index.js', './src/index.html'],
    • 有人可能会说,入口怎么放 HTML 文件,因为开发模式下热更新如果不设置入口为 HTML,那么更改了HTML 文件内容,是不会刷新页面的,需要手动刷新,所以这里给了入口 HTML 文件,一个细节。
  • 出口(output)

    • output 属性告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件,默认值为 ./dist。基本上,整个应用程序结构,都会被编译到你指定的输出路径的文件夹中。你可以通过在配置中指定一个 output 字段,来配置这些处理过程:
    webpack.config.js
    
    const path = require('path');
    
    module.exports = {
      entry: './path/to/my/entry/file.js',
      output: {path: path.resolve(__dirname, 'dist'),
        filename: 'my-first-webpack.bundle.js'
      }
    };

在上面的示例中,我们通过 output.filenameoutput.path 属性,来告诉 webpack bundle 的名称,以及我们想要 bundle 生成 (emit) 到哪里。可能你想要了解在代码最上面导入的 path 模块是什么,它是一个 Node.js 核心模块,用于操作文件路径。

  • loader

    • loader 让 webpack 能够去处理那些非 JavaScript 文件(webpack 自身只理解 JavaScript)。loader 可以将所有类型的文件转换为 webpack 能够处理的有效模块,然后你就可以利用 webpack 的打包能力,对它们进行处理。
    • 本质上,webpack loader 将所有类型的文件,转换为应用程序的依赖图(和最终的 bundle)可以直接引用的模块。
    • 注意,loader 能够 import 导入任何类型的模块(例如 .css 文件),这是 webpack 特有的功能,其他打包程序或任务执行器的可能并不支持。我们认为这种语言扩展是有很必要的,因为这可以使开发人员创建出更准确的依赖关系图。
    • 在更高层面,在 webpack 的配置中 loader 有两个目标:
    • test 属性,用于标识出应该被对应的 loader 进行转换的某个或某些文件。
    • use 属性,表示进行转换时,应该使用哪个 loader。

          webpack.config.js
          
          const path = require('path');
          
          const config = {
            output: {filename: 'my-first-webpack.bundle.js'},
            module: {
              rules: [{ test: /\.txt$/, use: 'raw-loader'}
              ]
            }
          };
          
          module.exports = config;
    • 以上配置中,对一个单独的 module 对象定义了 rules 属性,里面包含两个必须属性:test 和 use。这告诉 webpack 编译器(compiler) 如下信息:
    • “嘿,webpack 编译器,当你碰到「在 require()/import 语句中被解析为 '.txt' 的路径」时,在你对它打包之前,先使用 raw-loader 转换一下。”
    • 重要的是要记得,在 webpack 配置中定义 loader 时,要定义在 module.rules 中,而不是 rules。然而,在定义错误时 webpack 会给出严重的警告。为了使你受益于此,如果没有按照正确方式去做,webpack 会“给出严重的警告”
    • loader 还有更多我们尚未提到的具体配置属性。
    • 这里引用这位作者的优质文章内容,手写一个 loaderplugin 手写一个 loader 和 plugin

高潮来了,webpack的编译原理,为什么要先学学习原理?因为你起码得知道你写的是干什么的!

  • webpack打包原理

    • 识别入口文件
    • 通过逐层识别模块依赖。(Commonjs、amd或者 es6 的 import,webpack 都会对其进行分析。来获取代码的依赖)
    • webpack做的就是分析代码。转换代码,编译代码,输出代码
    • 最终形成打包后的代码
    • 这些都是 webpack 的一些基础知识,对于理解 webpack 的工作机制很有帮助。
    • 什么是loader

      • loader是文件加载器,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中
      • 处理一个文件可以使用多个 loaderloader 的执行顺序是和本身的顺序是相反的,即最后一个 loader 最先执行,第一个 loader 最后执行。
      • 第一个执行的 loader 接收源文件内容作为参数,其他 loader 接收前一个执行的 loader 的返回值作为参数。最后执行的 loader 会返回此模块的 JavaScript 源码
      • 在使用多个 loader 处理文件时,如果要修改 outputPath 输出目录,那么请在最上面的loader 中 options 设置
    • 什么是plugin?

      • Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
      • plugin 和 loader的区别是什么?
      • 对于loader,它就是一个转换器,将 A 文件进行编译形成 B 文件,这里操作的是文件,比如将 A.scss 或 A.less 转变为 B.css,单纯的文件转换过程
      • plugin是一个扩展器,它丰富了 wepack 本身,针对是 loader 结束后,webpack打包的整个过程,它并不直接操作文件,而是基于事件机制工作,会监听 webpack 打包过程中的某些节点,执行广泛的任务。
    • webpack的运行

      • webpack 启动后,在读取配置的过程中会先执行 new MyPlugin(options) 初始化一个 MyPlugin 获得其实例。在初始化 compiler 对象后,再调用 myPlugin.apply(compiler) 给插件实例传入 compiler 对象。插件实例在获取到 compiler 对象后,就可以通过 compiler.plugin(事件名称, 回调函数) 监听到 Webpack 广播出来的事件。并且可以通过 compiler 对象去操作 webpack
      • 看到这里可能会问 compiler 是啥,compilation又是啥?
      • Compiler 对象包含了 Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,这个对象在 Webpack 启动时候被实例化,它是全局唯一的,可以简单地把它理解为 Webpack 实例;
      • Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 Compilation 将被创建。Compilation 对象也提供了很多事件回调供插件做扩展。通过 Compilation 也能读取到 Compiler 对象。
      • CompilerCompilation 的区别在于:
      • Compiler 代表了整个 Webpack 从启动到关闭的生命周期,而 Compilation 只是代表了一次新的编译。
    • 事件流

      • webpack 通过  Tapable  来组织这条复杂的生产线。
      • webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。
      • webpack的事件流机制应用了观察者模式,和 Node.js 中的 EventEmitter 非常相似。

    下面正式开始开发环境的配置:

    • 入口设置:

      • 设置 APP,几个入口文件,即会最终分割成几个chunk
      • 在入口中配置 vendor,可以code spliting ,将这些公共的复用代码最终抽取成一个chunk,单独打包出来
      • 要想在开发模式中 HMTL 文件也热更新,需要加入·index.html为入口文件
        entry: {app: ['./src/index.js', './src/index.html'],
                vendor: ['react']  // 这里还可以加入 redux react-redux better-scroll 等公共代码 
            },
    • output出口

      • webpack基于 Node.js 环境运行,可以使用 Node.jsAPIpath模块的 resolve 方法
      • 对输出的 JS 文件,加入 contenthash 标示,让浏览器缓存文件,区别版本。
         output: {filename: '[name].[contenthash:8].js',
                path: resolve(__dirname, '../dist')
            },
    • mode: 'development' 模式选择,这里直接设置成开发模式,先从开发模式开始。
    • resolve解析配置,为了为了给所有文件后缀省掉 js jsx json,加入配置

      resolve: {extensions: [".js", ".json", ".jsx"]
      }
    • 加入插件 热更新 pluginhtml-webpack-plugin

         
         const HtmlWebpackPlugin = require('html-webpack-plugin')
         const webpack = require('webpack')
         new HtmlWebpackPlugin({template: './src/index.html'}),
         new webpack.HotModuleReplacementPlugin(),
    • 加入 babel-loader 还有 解析 JSX ES6 语法的 babel preset

      • @babel/preset-react解析 jsx 语法
      • @babel/preset-env解析 es6 语法
      • @babel/plugin-syntax-dynamic-import解析 react-loadableimport按需加载,附带 code spliting 功能
        {test: /\.(js|jsx)$/,
            use:
            {
                loader: 'babel-loader',
                options: {presets: ["@babel/preset-react", ["@babel/preset-env", { "modules": false}]],
                    plugins: ["@babel/plugin-syntax-dynamic-import"]
                         },
    
            }
       },
      
    • React的按需加载,附带代码分割功能,每个按需加载的组件打包后都会被单独分割成一个文件
    
            import React from 'react'
            import loadable from 'react-loadable'
            import Loading from '../loading' 
            const LoadableComponent = loadable({loader: () => import('../Test/index.jsx'),
                loading: Loading,
            });
            class Assets extends React.Component {render() {
                    return (
                        <div>
                            <div> 这即将按需加载 </div>
                            <LoadableComponent />
                        </div>
                    )
                }
            }
            
            export default Assets
    
    • 加入 html-loader 识别 html 文件
        {test: /\.(html)$/,
        loader: 'html-loader'
        }
    • 加入eslint-loader
            {
            enforce:'pre',
            test:/\.js$/,
            exclude:/node_modules/,
            include:resolve(__dirname,'/src/js'),
            loader:'eslint-loader'
            }
    • 开发模式结束 代码在下面
    const {resolve} = require('path');
    const HtmlWebpackPlugin = require('html-webpack-plugin')
    const webpack = require('webpack')
    const WorkboxPlugin = require('workbox-webpack-plugin')
    module.exports = {
        entry: {app: ['./src/index.js', './src/index.html'],
            vendor: ['react',]
        },
        output: {filename: '[name].[hash:8].js',
            path: resolve(__dirname, '../build')
        },
        module: {
            rules: [
                {
                    enforce:'pre',
                    test:/\.js$/,
                    exclude:/node_modules/,
                    include:resolve(__dirname,'/src/js'),
                    loader:'eslint-loader'
                    },
                {
                    oneOf: [{test: /\.(html)$/,
                        loader: 'html-loader'
                    },
                {test: /\.(js|jsx)$/,
                    use:
                    {
                        loader: 'babel-loader',
                        options: {presets: ["@babel/preset-react", ["@babel/preset-env", { "modules": false}]],
                            plugins: ["@babel/plugin-syntax-dynamic-import"]
                        },
    
                    }
                },
                {test: /\.(less)$/,
                    use: [{ loader: 'style-loader'},
                        {
                            loader: 'css-loader', options: {
                                modules: true,
                                localIdentName: '[local]--[hash:base64:5]'
                            }
                        },
                        {loader: 'less-loader'}
                    ]
                }, {test: /\.(jpg|jpeg|bmp|svg|png|webp|gif)$/,
                    loader: 'url-loader',
                    options: {
                        limit: 8 * 1024,
                        name: '[name].[hash:8].[ext]',
                        
                    }
                }, {exclude: /\.(js|json|less|css|jsx)$/,
                    loader: 'file-loader',
                    options: {
                        outputPath: 'media/',
                        name: '[name].[hash].[ext]'
                    }
                }
                ]
            }]
        },
        plugins: [
            new HtmlWebpackPlugin({template: './src/index.html'}),
            new webpack.HotModuleReplacementPlugin(),
            new webpack.NamedModulesPlugin(), // 打包时候可以看到文件名的插件],
        mode: 'development',
        devServer: {
            contentBase: '../build',
            open: true,
            port: 3000,
            hot: true
        },
        resolve: {extensions: [".js", ".json", ".jsx"]
        }
    }

    必须了解的 webpack 热更新原理:

    • webpack的热更新又称热替换(Hot Module Replacement),缩写为HMR。这个机制可以做到不用刷新浏览器而将新变更的模块替换掉旧的模块。

    • 首先要知道 server 端和 client 端都做了处理工作

      • 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。
      • 第二步是 webpack-dev-server webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API 对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。
      • 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。
      • 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。
      • webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。
      • HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。
      • 而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
      • 最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。
      • 参考文章 webpack 面试题 - 腾讯云

    正式开始生产环节:

    • 加入 WorkboxPluginPWA的插件

      • pwa这个技术其实要想真正用好,还是需要下点功夫,它有它的生命周期,以及它在浏览器中热更新带来的副作用等,需要认真研究。可以参考百度的 lavas 框架发展历史~
    const WorkboxPlugin = require('workbox-webpack-plugin')
    
    
        new WorkboxPlugin.GenerateSW({ 
                    clientsClaim: true, // 让浏览器立即 servece worker 被接管
                    skipWaiting: true,  // 更新 sw 文件后,立即插队到最前面 
                    importWorkboxFrom: 'local',
                    include: [/\.js$/, /\.css$/, /\.html$/,/\.jpg/,/\.jpeg/,/\.svg/,/\.webp/,/\.png/],
                }),
            
    • 加入每次打包输出文件清空上次打包文件的插件
        const CleanWebpackPlugin = require('clean-webpack-plugin')
        
        new CleanWebpackPlugin()
    • 加入 code spliting 代码分割
        optimization: {
                runtimeChunk:true,  // 设置为 true, 一个 chunk 打包后就是一个文件,一个 chunk 对应 ` 一些 js css 图片 ` 等
                splitChunks: {
                    chunks: 'all'  // 默认 entry 的 chunk 不会被拆分, 配置成 all, 就可以了拆分了,一个入口 `JS`,// 打包后就生成一个单独的文件
                }
            }
    • 加入单独抽取 CSS 文件的 loader 和插件
    const MiniCssExtractPlugin = require('mini-css-extract-plugin')
    
        {test: /\.(less)$/,
            use: [
                MiniCssExtractPlugin.loader,
                {
                    loader: 'css-loader', options: {
                        modules: true,
                        localIdentName: '[local]--[hash:base64:5]'
                    }
                },
                {loader:'postcss-loader'},
                {loader: 'less-loader'}
            ]
        }
        
         new MiniCssExtractPlugin({filename:'[name].[contenthash:8].css'
            }),
    
    • 加入压缩 css 的插件
        const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
        new OptimizeCssAssetsWebpackPlugin({
                    cssProcessPluginOptions:{preset:['default',{discardComments: {removeAll:true} }]
                    }
                }),
    • 杀掉 html 一些没用的代码
        new HtmlWebpackPlugin({
            template: './src/index.html',
            minify: {
                removeComments: true,  
                collapseWhitespace: true,  
                removeRedundantAttributes: true,
                useShortDoctype: true, 
                removeEmptyAttributes: true,
                removeStyleLinkTypeAttributes: true,
                keepClosingSlash: true, 
                minifyJS: true,
                minifyCSS: true, 
                minifyURLs: true, 
             }
    }),
    • 加入图片压缩
    {test: /\.(jpg|jpeg|bmp|svg|png|webp|gif)$/,
                    
                    use:[
                        {loader: 'url-loader',
                        options: {
                            limit: 8 * 1024,
                            name: '[name].[hash:8].[ext]',
                            outputPath:'/img'
                        }},
                        {
                            loader: 'img-loader',
                            options: {
                              plugins: [require('imagemin-gifsicle')({interlaced: false}),
                                require('imagemin-mozjpeg')({
                                  progressive: true,
                                  arithmetic: false
                                }),
                                require('imagemin-pngquant')({
                                  floyd: 0.5,
                                  speed: 2
                                }),
                                require('imagemin-svgo')({
                                  plugins: [{ removeTitle: true},
                                    {convertPathData: false}
                                  ]
                                })
                              ]
                            }
                          }
                    ]
                    
                    
    
                }
    • 加入file-loader 把一些文件打包输出到固定的目录下
    {exclude: /\.(js|json|less|css|jsx)$/,
                    loader: 'file-loader',
                    options: {
                        outputPath: 'media/',
                        name: '[name].[contenthash:8].[ext]'
                    }
                }
                

    里面有一些注释可能不详细,代码都是自己一点点写,试过的,肯定没用任何问题

    • 需要的依赖
    {
        "name": "webpack",
        "version": "1.0.0",
        "main": "index.js",
        "license": "MIT",
        "dependencies": {
            "@babel/core": "^7.4.4",
            "@babel/preset-env": "^7.4.4",
            "@babel/preset-react": "^7.0.0",
            "autoprefixer": "^9.5.1",
            "babel-loader": "^8.0.5",
            "clean-webpack-plugin": "^2.0.2",
            "css-loader": "^2.1.1",
            "eslint": "^5.16.0",
            "eslint-loader": "^2.1.2",
            "file-loader": "^3.0.1",
            "html-loader": "^0.5.5",
            "html-webpack-plugin": "^3.2.0",
            "imagemin": "^6.1.0",
            "imagemin-gifsicle": "^6.0.1",
            "imagemin-mozjpeg": "^8.0.0",
            "imagemin-pngquant": "^7.0.0",
            "imagemin-svgo": "^7.0.0",
            "img-loader": "^3.0.1",
            "less": "^3.9.0",
            "less-loader": "^5.0.0",
            "mini-css-extract-plugin": "^0.6.0",
            "optimize-css-assets-webpack-plugin": "^5.0.1",
            "postcss-loader": "^3.0.0",
            "react": "^16.8.6",
            "react-dom": "^16.8.6",
            "react-loadable": "^5.5.0",
            "react-redux": "^7.0.3",
            "style-loader": "^0.23.1",
            "url-loader": "^1.1.2",
            "webpack": "^4.30.0",
            "webpack-cli": "^3.3.2",
            "webpack-dev-server": "^3.3.1",
            "workbox-webpack-plugin": "^4.3.1"
        },
        "scripts": {
            "start": "webpack-dev-server --config ./config/webpack.dev.js",
            "dev": "webpack-dev-server --config ./config/webpack.dev.js",
            "build": "webpack  --config  ./config/webpack.prod.js"
        },
        "devDependencies": {"@babel/plugin-syntax-dynamic-import": "^7.2.0"}
    }
    
    

    整个项目和 webpack 配置的源码地址 : 源码地址啊 看得见吗亲

    路过的小伙伴麻烦点个赞给个 star,写得好辛苦啊!!!!

    退出移动版