自动清理构建目录产物

每次构建的时候不会清理目录,造成构建的输出目录 output 文件越来越多。

通过 npm scripts 清理构建目录

  1. rm -rf ./dist && webpack
  2. rimraf ./dist && webpack

使用 clean-webpack-plugin

npm i clean-webpack-plugin -D

避免构建前每次都需要手动删除 dist。

默认会删除 output 指定的输出目录

webpack.prod.js

const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    // ...
    new CleanWebpackPlugin()
  ]
}

测试环境也可以使用此插件。

自定补齐 CSS3 前缀

需求原因:各大浏览器写法并没有统一。

browser.png

举个例子。

.box {
  -moz-border-radius: 10px;
  -webkit-border-radius: 10px;
  -o-border-radius: 10px;
  border-radius: 10px;
}

使用 PostCSS 插件 autoprefixer 插件。根据 Can I Use 规则(https://caniuse.com/)。

安装

npm i postcss-loader autoprefixer -D

配置

index.less

.text {
  font-size: 20px;
  color: red;
  display: flex;
}

postcss.config.js

module.exports = {
  plugins: [
    require('autoprefixer')({
      "overrideBrowserslist": [
        "defaults",
        "not ie < 11",
        "last 2 versions",
        "> 1%",
        "iOS 7",
        "last 3 iOS versions"
      ]
    })
  ]
}

webpack.prod.js

module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      },
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'less-loader',
          'postcss-loader'
        ]
      }
  },
  // ...
}

移动端 CSS px 自动转换成 rem

针对浏览器的分辨率。需要进行页面适配。

主要针对 ios 出很多新的设备,不同的设备的分辨率都不同。

设备适配常用的方法。

CSS 媒体查询实现响应式布局

缺陷:需要写多套适配样式代码。

@media screen and (max-width: 980px) {
    .header {
        width: 900px;
    }
}

@media screen and (max-width: 480px) {
    .header {
        width: 400px;
    }
}

@media screen and (max-width: 350px) {
    .header {
        width: 300px;
    }
}

rem 是什么

W3C 对 rem 的定义:font-size of the root element。

rem 与 px 对比:

  • rem 是相对单位
  • px 是绝对单位

使用 px2rem-loader

px2rem-loader 将 px 转换成 rem。

npm i px2rem-loader -D

页面渲染时计算根元素的 font-size 值。

npm i lib-flexible -S

px2rem-loader 只是以构建的手段将 px 单位转换成了 rem。但是 rem 和 px 的单位计算并不清楚,flexible.js 的作用就是动态的去计算不同设备下的 rem 相对 px 的单位,也就是计算跟元素 html 节点的 font-size 大小。 这个比较适合H5和Pad端的应用,PC端还是使用px比较适合。

配置

webpack.prod.js

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader'
        ]
      },
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'less-loader',
          'postcss-loader',
          {
            loader: 'px2rem-loader',
            options: {
              remUnit: 75, // 1rem = 75px、适合 750 设计稿
              remPrecesion: 8 // px => rem 小数点的位数
            }
          }
        ]
      },
        // ...
  }
  // ...
}

index.html

先手动引入 lib-flexible 库,后面再学习如何内联库到页面。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <script type="text/javascript">
    ;(function(win, lib) {
        var doc = win.document;
        var docEl = doc.documentElement;
        var metaEl = doc.querySelector('meta[name="viewport"]');
        var flexibleEl = doc.querySelector('meta[name="flexible"]');
        var dpr = 0;
        var scale = 0;
        var tid;
        var flexible = lib.flexible || (lib.flexible = {});

        if (metaEl) {
            console.warn('将根据已有的meta标签来设置缩放比例');
            var match = metaEl.getAttribute('content').match(/initial\-scale=([\d\.]+)/);
            if (match) {
                scale = parseFloat(match[1]);
                dpr = parseInt(1 / scale);
            }
        } else if (flexibleEl) {
            var content = flexibleEl.getAttribute('content');
            if (content) {
                var initialDpr = content.match(/initial\-dpr=([\d\.]+)/);
                var maximumDpr = content.match(/maximum\-dpr=([\d\.]+)/);
                if (initialDpr) {
                    dpr = parseFloat(initialDpr[1]);
                    scale = parseFloat((1 / dpr).toFixed(2));
                }
                if (maximumDpr) {
                    dpr = parseFloat(maximumDpr[1]);
                    scale = parseFloat((1 / dpr).toFixed(2));
                }
            }
        }

        if (!dpr && !scale) {
            var isAndroid = win.navigator.appVersion.match(/android/gi);
            var isIPhone = win.navigator.appVersion.match(/iphone/gi);
            var devicePixelRatio = win.devicePixelRatio;
            if (isIPhone) {
                // iOS下,对于2和3的屏,用2倍的方案,其余的用1倍方案
                if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {
                    dpr = 3;
                } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
                    dpr = 2;
                } else {
                    dpr = 1;
                }
            } else {
                // 其他设备下,仍旧使用1倍的方案
                dpr = 1;
            }
            scale = 1 / dpr;
        }

        docEl.setAttribute('data-dpr', dpr);
        if (!metaEl) {
            metaEl = doc.createElement('meta');
            metaEl.setAttribute('name', 'viewport');
            metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
            if (docEl.firstElementChild) {
                docEl.firstElementChild.appendChild(metaEl);
            } else {
                var wrap = doc.createElement('div');
                wrap.appendChild(metaEl);
                doc.write(wrap.innerHTML);
            }
        }

        function refreshRem(){
            var width = docEl.getBoundingClientRect().width;
            if (width / dpr > 540) {
                width = 540 * dpr;
            }
            var rem = width / 10;
            docEl.style.fontSize = rem + 'px';
            flexible.rem = win.rem = rem;
        }

        win.addEventListener('resize', function() {
            clearTimeout(tid);
            tid = setTimeout(refreshRem, 300);
        }, false);
        win.addEventListener('pageshow', function(e) {
            if (e.persisted) {
                clearTimeout(tid);
                tid = setTimeout(refreshRem, 300);
            }
        }, false);

        if (doc.readyState === 'complete') {
            doc.body.style.fontSize = 12 * dpr + 'px';
        } else {
            doc.addEventListener('DOMContentLoaded', function(e) {
                doc.body.style.fontSize = 12 * dpr + 'px';
            }, false);
        }


        refreshRem();

        flexible.dpr = win.dpr = dpr;
        flexible.refreshRem = refreshRem;
        flexible.rem2px = function(d) {
            var val = parseFloat(d) * this.rem;
            if (typeof d === 'string' && d.match(/rem$/)) {
                val += 'px';
            }
            return val;
        }
        flexible.px2rem = function(d) {
            var val = parseFloat(d) / this.rem;
            if (typeof d === 'string' && d.match(/px$/)) {
                val += 'rem';
            }
            return val;
        }

    })(window, window['lib'] || (window['lib'] = {}));
  </script>
</head>
<body>

  <div id="app"></div>

</body>
</html>

其他问题

1. 统一转化 rem 时,有部分样式不想转化。

后面有 /*no*/ 这种注释语法会不进行 rem 的转换。

.page {
  font-size: 12px; /*no*/
  width: 375px; /*no*/
  height: 40px;
}

2. 关于 手淘的lib-flexible库 与 vw 方法。

html: {
  font-size: calc(100vw / 固定分辨率);
}

手淘的这个库有个好处,它会比较方便的解决手机端的1px问题。

3. 不推荐使用rem了么,现在项目里面用vw用的更多一些

针对兼容性来说,rem 兼容性更好。

4. px2rem-loader 会把第三方 ui 库的 px 也给转换

px2rem-loader 也是可以设置 exclude 的,可以把 node_modules 里面的模块 exclude 掉。

如果不设置 exclude,那么也可以使用 /no/的语法去设置某一行样式不进行 px2rem 的转换操作。

5. lib-flex一定要内里联进来吗,可以引入文件吗?

这个是必须要内联进来的,因为页面打开的时候就需要马上计算页面的根节点的 font-size 值。

如果不内联进来而是打包到了 js 里面去,那么样式解析的时候会有问题,可能存在样式闪动的情况。

6. vm布局和rem哪个适配好,都有什么不同

主要是兼容性上。rem兼容性更好,支持 android2.2 以上的机型。但是vm只支持 android4.4 和 ios8 以上的。

另外rem需要的计算需要在头部内联一个脚本,vm是纯css去实现的。如果不考虑兼容性,vm完全没问题。

7. 内联样式该如何转为rem?

内联的样式如果想转换成rem需要自己实现 loader 去解析 html 文件。

可以实现一个 loader,然后这个 loader 去匹配 html,然后将 html 里面的 px 都转换成 rem就可以。

8. 按照 750 的设计稿,直接就是10px写10px吗? 为什么看着比设计稿的要大?

如果设置的 remUnit 是 75,那么对于 750 的设计稿如果字体是 24px,就写 24px(实际上在 iphone 6是12px的大小)。

如果设置的 remUnit 是 37.5,那么对于 375 的设计稿如果字体是 12px,就写 12px(实际上在 iphone 6是12px的大小)。

看着比设计稿的要大这个需要以 iphone 6 为参照。

资源内联

资源内联的意义

代码层面

  • 页面框架的初始化脚本
  • 上报相关打点
  • css 内联避免页面闪动

请求层面

  • 小图片或者字体内联(url-loader)

HTML 内联

<%= require('raw-loader!./meta.html') %>

JS 内联

<script><%= require('raw-loader!babel-loader!../node_modules/lib-flexible/flexible.js') %></script>

CSS 内联

方案一:借助 style-loader

{
  loader: 'style-loader',
  options: {
    insertAt: 'top', // 样式插入到 <head>
    singleton: true, // 将所有的 style 标签合并成一个
  }
}

css-loader 将css转换成commonjs对象,然后css代码就在js里面了。 style-loader的作用是在js执行时,动态的创建style标签,然后将 css-loader 转换的样式插入到这个style标签里去的。 所以在打包之后的 html 文件中找不到内联的 css 文件,只能运行时看到,也就是在浏览器可以看到。

方案二:html-inline-css-webpack-plugin

将页面打包过程的产生的所有 CSS 提取成一个独立的文件,然后将这个 CSS 文件内联进 HTML head 里面。这里需要借助 mini-css-extract-plugin 和 html-inline-css-webpack-plugin 来实现 CSS 的内联功能。

module.exports = {
  // ...
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css'
    }),
    new HtmlWebpackPlugin(),
    new HTMLInlineCSSWebpackPlugin()
  ]
};

注:html-inline-css-webpack-plugin 需要放在 html-webpack-plugin 后面。

配置

npm i raw-loader@0.5.1 -D

安装的版本是 0.5.1 的版本,最新的版本导出模块的时候使用了 export default 语法, html 里面用的话有问题。 raw-loader 内联进去的样式或者脚本的压缩都是基于 html-webpack-plugin 进行处理的。 如果想压缩内联的文件,可以配置 html-webpack-plugin 的 minify参数,用于压缩 html 里面内联的内容。

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <%= require('raw-loader!./meta.html') %>
  <title>Document</title>
  <script><%= require('raw-loader!babel-loader!../node_modules/lib-flexible/flexible.js') %></script>
</head>
<body>

  <div id="app"></div>

</body>
</html>

<%= %> 语法是 html-webpack-plugin 插件支持的 ejs 写法。

关于图片、字体内联等,可以参考 这篇文章

多页面应用打包通用方案

多页面应用(MPA)概念

每一次页面跳转的时候,后台服务器都会返回一个新的 html 文档,这种类型的网站也就是多页网站,也叫做多页应用。

基本思路

每个页面对应一个 entry,一个 html-webpack-plugin。

缺点:每次新增或删除页面需要改 webpack 配置。

通用方案

动态获取 entry 和设置 html-webpack-plugin 数量。

利用 glob.sync。

entry: glob.sync(path.join(__dirname, './src/*/index.js'))

比如上述配置需要所有的文件都放在 src 目录下,每个目录的文件名称都是 index.js 文件。

配置

npm i glob -D

页面结构整理

floder.png

webpack.prod.js

const path = require('path');
const glob = require('glob');

const setMPA = () => {
  const entry = {};
  const htmlWebpackPlugins = [];

  const entryFiles = glob.sync(path.join(__dirname, './src/pages/*/index.js'))

  entryFiles.map((entryFile) => {
    const match = entryFile.match(/src\/pages\/(.*)\/index\.js/);
    const pageName = match && match[1];

    entry[pageName] = entryFile;

    htmlWebpackPlugins.push(
      new HtmlWebpackPlugin({
        template: path.resolve(__dirname, `src/pages/${pageName}/index.html`),
        filename: `${ pageName }.html`,
        chunks: [ pageName ],
        excludeChunks: ['node_modules'],
        inject: true,
        minify: {
          html5: true,
          collapseWhitespace: true,
          preserveLineBreaks: false,
          minifyCSS: true,
          minifyJS: true,
          removeComments: false
        }
      }),
    )
  });

  return {
    entry,
    htmlWebpackPlugins
  }
}

const { entry, htmlWebpackPlugins }  = setMPA();

module.exports = {
  entry,
  output: {
    filename: '[name][chunkhash:8].js',
    path: path.resolve(__dirname, 'dist')
  },
  mode: "production",
  // ...
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css'
    }),
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: require('cssnano')
    }),
    new CleanWebpackPlugin()
  ].concat(htmlWebpackPlugins)
}

使用 sourceMap

可以通过 source map 定位到源代码。

开发环境开启,线上环境关闭。线上排查问题的时候可以将 sourcemap 上传到错误监控系统。

source map 关键字

  • eval:使用 eval 包裹模块代码
  • source map:产生 .map 文件
  • cheap:不包含列信息
  • inline:将 .map 作为 DataURL 嵌入,不单独生成 .map 文件
  • module:包含 loader 的 sourcemap

source map 类型

devtool 首次构建 二次构建 是否适合生产环境 可以定位的代码
none +++ +++ yes 最终输出的代码
eval +++ +++ no webpack 生成的代码(一个个的模块)
cheap-eval-source-map * ++ no 经过 loader 转换后的代码(只能看到行)
cheap-module-eval-source-map o ++ no 源代码(只能看到行)
eval-source-map + no 源代码
cheap-source-map + o yes 经过 loader 转换后的代码(只能看到行)
cheap-module-source-map o - yes 源代码(只能看到行)
inline-cheap-source-map + o no 经过 loader 转换后的代码(只能看到行)
inline-cheap-module-source-map o - no 源代码(只能看到行)
source-map yes 源代码
inline-source-map no 源代码
hidden-source-map yes 源代码

配置

webpack.dev.js

module.exports = {
  mode: "development",
  // ...
  devServer: {
    contentBase: './dist',
    hot: true
  },
  devtool: 'source-map'
}

提取页面公共资源

基础库分离

1. 利用 html-webpack-externals-plugin 。

将 react、react-dom 基础包通过 cdn 引入,不打入 bundle 中。

npm i html-webpack-externals-plugin -D

webpack.prod.js

const path = require('path');
const glob = require('glob');

// ...
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');

const setMPA = () => {
  const entry = {};
  const htmlWebpackPlugins = [];
  const htmlWebpackExternalsPlugins = [];

  const entryFiles = glob.sync(path.join(__dirname, './src/pages/*/index.js'))

  entryFiles.map((entryFile) => {
    const match = entryFile.match(/src\/pages\/(.*)\/index\.js/);
    const pageName = match && match[1];

    entry[pageName] = entryFile;

    // ...
    htmlWebpackExternalsPlugins.push(
      new HtmlWebpackExternalsPlugin({
        externals: [
          {
            module: 'react',
            entry: 'https://11.url.cn/now/lib/16.2.0/react.min.js',
            global: 'React'
          },
          {
            module: 'react-dom',
            entry: 'https://11.url.cn/now/lib/16.2.0/react-dom.min.js',
            global: 'ReactDOM'
          },
        ],
        files: [`${pageName}.html`]
      })
    )
  });

  return {
    entry,
    htmlWebpackPlugins,
    htmlWebpackExternalsPlugins
  }
}

const { entry, htmlWebpackPlugins, htmlWebpackExternalsPlugins }  = setMPA();

module.exports = {
  // ...
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css'
    }),
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: require('cssnano')
    }),
    new CleanWebpackPlugin(),
  ].concat(htmlWebpackExternalsPlugins, htmlWebpackPlugins)
}

2. 利用 SplitChunksPlugin。

webpack4 内置的,替代 CommonsChunksPlugin 插件。

chunks 参数说明:

  • async 异步引入的库进行分离(默认)
  • initial 同步引入的库进行分离
  • all 所有引入的库进行分离(推荐)

详细参数可以去 webpack 官网查看。

test 匹配出需要分离的包。

module.exports = {
   // ....
   plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css'
    }),
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: require('cssnano')
    }),
    new CleanWebpackPlugin(),
  ].concat(htmlWebpackPlugins),
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /(react|react-dom)/,
          name: 'vendors',
          chunks: 'all'
        }
      }
    }
  }
}

不需要安装手动 SplitChunksPlugin,内置插件。

公共文件分离

利用 SplitChunksPlugin 分离页面公共文件。

minChunks:设置最小引用次数为 2 次

minSize:分离包体积的大小

module.exports = {
  optimization: {
    splitChunks: {
      minSize: 0,
      cacheGroups: {
        commons: {
          name: 'commons',
          chunks: 'all',
          minChunks: 2
        }
      }
    }
  }
}

基础库和公共文件分离

搭配使用,使用 priority 权重属性。值越大,优先级越高.模块先打包到优先级高的组里。

module.exports = {
   // ....
   plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css'
    }),
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: require('cssnano')
    }),
    new CleanWebpackPlugin(),
  ].concat(htmlWebpackPlugins),
  optimization: {
    splitChunks: {
      minSize: 0,
      cacheGroups: {
        vendors: {
          test: /(react|react-dom)/,
          name: 'vendors',
          chunks: 'all',
          priority: -10
        },
        commons: {
          name: 'commons',
          chunks: 'all',
          minChunks: 2,
          priority: -20
        }
      }
    }
  }
}

其他相关配置可以参考下面。

module.exports = {
  //...
  optimization: {
    splitChunks: {
      // async:异步引入的库进行分离(默认), initial: 同步引入的库进行分离, all:所有引入的库进行分离(推荐)
      chunks: 'async',
      minSize: 30000, // 抽离的公共包最小的大小,单位字节
      maxSize: 0, // 最大的大小
      minChunks: 1, // 资源使用的次数(在多个页面使用到), 大于1, 最小使用次数
      maxAsyncRequests: 5, // 并发请求的数量
      maxInitialRequests: 3, // 入口文件做代码分割最多能分成3个js文件
      automaticNameDelimiter: '~', // 文件生成时的连接符
      automaticNameMaxLength: 30, // 自动自动命名最大长度
      name: true, //让cacheGroups里设置的名字有效
      cacheGroups: { //当打包同步代码时,上面的参数生效
        vendors: {
          test: /[\\/]node_modules[\\/]/, // 检测引入的库是否在node_modlues目录下的
          priority: -10, // 值越大,优先级越高.模块先打包到优先级高的组里
          filename: 'vendors.js'// 把所有的库都打包到一个叫vendors.js的文件里
        },
        default: {
          minChunks: 2, // 上面有
          priority: -20, // 上面有
          reuseExistingChunk: true //如果一个模块已经被打包过了,那么再打包时就忽略这个上模块
        }
      }
    }
  }
}

Tree Shaking 使用和原理分析

tree shaking(摇树优化)。

1 个模块可能有多个方法,只要其中的某个方法使用到了,则整个文件就会被打到 bundle 里面去。

tree shaking 就是只把用到的方法打入到 bundle,没用到的方法会在 unlify 阶段被删除掉。

使用

  • webpack 默认支持,在 .babelrc 中设置 modules: false 即可。
  • production mode 的情况下默认开启。

必须是 ES6 Module 的形式,CommonJS 的方式不支持

DCE

DCE(Dead code elimination),即死码消除。

它是一种编译最优化技术,它的用途是移除对程序运行结果没有任何影响的代码。

  • 代码不会被执行,不可到达。
  • 代码执行的结果不会被用到。
  • 代码只会影响死变量(只写不读)。
if (false) {
  console.log('这段代码永远不会执行');
}

原理

Tree-shaking 原理。

利用 ES6 模块特点。

  • 只能作为模块顶层的语句出现;
  • import 的模块名只能是字符串常量;
  • import binding 是 immutable 的。

代码擦除:unglify 阶段删除无用代码。

Tree-shaking 生效还有一个要求,就是编写的代码不能有副作用。

副作用这个概念来源于函数式编程(FP),纯函数是没有副作用的,也不依赖外界环境或者改变外界环境。 纯函数的概念是:接受相同的输入,任何情况下输出都是一样的。 非纯函数存在副作用,副作用就是:相同的输入,输出不一定相同。或者这个函数会影响到外部变量、外部环境。 函数如果调用了全局对象或者改变函数外部变量,则说明这个函数有副作用。

配置

tree-shaking.js

export function a () {
  return 'This is a func a';
}

export function b () {
  return 'This is a func b';
}

index.js

'use strict';

import React from 'react';
import ReactDOM from 'react-dom';

// ...

import { a } from './tree-shaking';

class Search extends React.Component {
  render () {
    const funcA = a;

    return (
      <div className="text">
        Hello React!!! { funcA } <img width={ 200 } height={ 200 } src={ avator } />
      </div>
    );
  }
}

ReactDOM.render(
  <Search />,
  document.getElementById('app')
);

webpack.prod.js

// ...

module.exports = {
  entry,
  output: {
    filename: '[name][chunkhash:8].js',
    path: path.resolve(__dirname, 'dist')
  },
  mode: "production"
  // ...
}

Scope Hoisting 使用和原理分析

现象:构建后的代码存在大量闭包代码。

// a.js
export default 'xxxx';

// b.js
import index from './a';
console.log(index);

大量函数闭包包裹代码,导致体积增大(模块越多越明显)。

运行代码时创建的函数作用域变多,内存开销变大。

被 webpack 转换后的模块会带上一层包裹。import 会被转换成 __webpack_require。

webpack_require

打包出来的是一个 IIFE(匿名闭包)。

modules 是一个数组,每一项是一个模块初始化函数。

__webpack_require 用来加载模块,返回 module.exports。

通过 WEBPACK_REQUEST_METHOD(0) 启动程序。

Scope hoisting 原理

原理:将所有模块的代码按照引用顺序放在一个函数作用域里,然后适当的重命名一些变量以防止变量名冲突。

通过 scope hoisting 可以减少函数声明代码和内存开销。

使用

webpack mode 为 prodution 默认开启,必须是 ES6 语法,CJS 不支持。

实际上默认开启 new webpack.optimize.ModuleConcatenationPlugin()。

Scope hoisting 对模块的引用次数大于1次是不产生效果的,如果一个模块引用次数大于1次,那么这个模块的代码会被内联多次,从而增加了打包出来的 js bundle 的体积。

代码分割与动态 import

代码分割的意义

对于大的的 Web 应用来讲,将所有的代码都放在一个文件中显然是不够有效的,特别是当你的某些代码块是在某些特殊的时候才会被使用到。webpack 有一个功能就是将你的代码分割成 chunks(语块),当代码运行到需要它们的时候在进行加载。

适用场景:

  • 抽离相同代码到一个共享块。
  • 脚本懒加载,使得初始下载的代码更小。

懒加载 JS 脚本的方式

CommonJS:require.ensure

ES6:动态 import(目前还没有原生支持,需要 babel 转换)。

如何使用动态 import

安装 babel 插件

npm i @babel/plugin-syntax-dynamic-import -D
{
  "plugins": [
    "@babel/plugin-syntax-dynamic-import"
  ]
}

配置

babelrc.js

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react"
  ],
  "plugins": [
    "@babel/plugin-syntax-dynamic-import"
  ]
}

dynamic.js

import React from 'react';

export default () => <div>动态 import</div>;

index.js

'use strict';

import React from 'react';
import ReactDOM from 'react-dom';

// ...

class Search extends React.Component {
  constructor () {
    super(...arguments);

    this.state = {
      Text: null
    };
  }

  // 动态加载组件
  loadComponent () {
    import('./dynamic').then((Text) => {
      this.setState({
        Text: Text.default
      });
    });
  }

  render () {
    const { Text } = this.state;

    return (
      <div className="text">
        <div>
          Hello React!!!
        </div>
        { Text ? <Text /> : null }
        <dvi>
          <img
            width={ 200 }
            height={ 200 }
            src={ avator }
            onClick={ this.loadComponent.bind(this) }
          />
        </dvi>
      </div>
    );
  }
}

ReactDOM.render(
  <Search />,
  document.getElementById('app')
);

使用 ESLint

行业中优秀的 ESLint 规范实践

Airbnb: eslint-config-airbnb、 eslint-config-airbnb-base

腾讯:

制定团队的 ESLint 规范

不重复造轮⼦,基于 eslint:recommend 配置并改进。

能够帮助发现代码错误的规则,全部开启。

帮助保持团队的代码⻛格统⼀,⽽不是限制开发体验。

rule.png

ESLint 如何执行落地

和 CI/CD 系统集成,和 webpack 集成。

webpack 与 CI/CD 集成

ci.png

本地开发阶段增加 precommit 钩子

安装 husky

npm i husky -D

增加 npm script,通过 lint-staged 增量检查修改的文件

"scripts": {
  "precommit": "lint-staged"
},
"lint-staged": {
  "linters": {
    "*.{js,scss}": ["eslint --fix", "git add"]
  }
}

webpack 与 ESLint 集成

使用 eslint-loader,构建时检查 JS 规范。推荐新项目,老项目不适合。

react 项目推荐使用 eslint-config-airbnb,非 react 项目推荐使用 eslint-config-airbnb-base。

npm i eslint eslint-plugin-import eslint-plugin-react eslint-plugin-jsx-a11y -D
npm i eslint-loader babel-eslint eslint-config-airbnb -D

.eslintrc.js

module.exports = {
  "parser": "babel-eslint",
  "extends": "airbnb",
  "env": {
    "browser": true,
    "node": true
  }
}

webpack.prod.js

module.exports = {
  entry,
  output: {
    filename: '[name][chunkhash:8].js',
    path: path.resolve(__dirname, 'dist')
  },
  mode: "production",
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          'babel-loader',
          'eslint-loader'
        ]
      },
      // ...
    ]
  }
}

webpack 打包组件和基础库 ★ ★ ★

webpack 除了可以用来打包应用,也可以用来打包 JS 库。

实现一个大整数加法库的打包。

  • 需要打包压缩版和非压缩版本。
  • 支持 AMD/CJS/ESM 模块引入。

Rollup 更适合打包组件和库。

库的目录结构和打包要求

打包输出的库名称:

  • 未压缩版 large-number.js
  • 压缩版 large-number.min.js

支持的使用方式

  • 支持 ES Module
  • 支持 CJS
  • 支持 AMD
  • 可以直接通过 script 引入

如何将库暴露出去

library:指定库的全局变量

libraryTarget:支持库引入的方式

配置

新建立一个项目。

npm i webpack webpack-cli -D

需要只对 .min 压缩。通过 include 设置只压缩 min.js 结尾的文件。

npm i terser-webpack-plugin -D

webpack.config.js

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'none',
  entry: {
    'large-number': './src/index.js',
    'largeg-number.min': './src/index.js'
  },
  output: {
    filename: '[name].js',
    library: 'largeNumber',
    libraryTarget: 'umd',
    libraryExport: 'default',
    globalObject: 'this',
    umdNamedDefine: true
  },
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        include: /\.min\.js$/
      })
    ]
  }
}

src/index.js

export default function add (a, b) {
  let i = a.length - 1;
  let j = b.length - 1;

  let carry = 0;
  let ret = '';

  while (i >= 0 || j>= 0) {
    let x = 0,
        y = 0,
        sum = 0;

    if (i >= 0) {
      x = a[i] - '0';
      i--;
    }

    if (j >= 0) {
      y = b[j] - '0';
      j--;
    }

    sum = x + y + carry;

    if (sum >= 10) {
      carry = 1;
      sum -= 10;
    } else {
      carry = 0;
    }

    ret = sum + ret;
  }

  if (carry) {
    ret = carry + ret;
  }

  return ret;
}

// add('999', '1');
// add('1', '999');
// add('123', '321');
// add('9999999999999999999999999999999999999999999999999999', '1');

还需要设置入口文件,主要是 package.json main 字段。

package.json

{
  "name": "yueluo-large-number",
  "version": "1.0.0",
  "description": "大整数加法打包",
  "main": "index.js",
  "scripts": {
    "build": "webpack",
    "prepublish": "webpack"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "terser-webpack-plugin": "^5.1.1",
    "webpack": "^5.24.2",
    "webpack-cli": "^4.5.0"
  }
}

index.js

if (process.env.NODE_ENV === 'production') {
  modules.exports = require('./dist/large-number.min.js');
} else {
  modules.exports = require('./dist/large-number.js');
}

最后可以使用 npm 命令推到 npm 仓库中,然后在项目中安装测试。

webpack 实现 SSR 打包

SSR 是什么

渲染:HTML + CSS + JS + Data => 渲染后的 HTML。

服务端:

  • 所有模板等资源都存储在服务端。
  • 内网机器拉取数据更快。
  • 一个 HTML 返回所有数据。

浏览器和服务器交互流程

server_render.png

客户端渲染和服务端渲染

server_render02.png

总结:服务端渲染(SSR)的核心是减少请求。

SSR 优势

减少白屏时间。

对 SEO 优化好。

实现思路

服务端

  • 使用 react-dom/server 的 renderToString 方法将 React 组件渲染成字符串。
  • 服务端返回对应的模板。

客户端

  • 打包出针对服务端的组件。

配置

npm i express -D

server/index.js

const express = require('express');
const { renderToString } = require('react-dom/server');

const SEARCH_SSR = require('../dist/search-server');

const server = (port) => {
  const app = express();

  app.use(express.static('dist'));

  app.get('/search', (req, res) => {
    const htmlStr = renderMarkUp(renderToString(SEARCH_SSR));
    res.status(200).send(htmlStr);
  });

  app.listen(port, () => {
    console.log('Server is running on port:', port);
  });
}

server(process.env.PORT || 3000);

const renderMarkUp = (str) => {
  return `
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <div id="app>${str}</div>
    </body>
    </html>
  `;
}

src/search/index-index.js

'use strict';

const React = require('react');

require('../../common/index');
require('./index.less');

class Search extends React.Component {
  render () {
    return (
      <div className="text">
        <div>
          Hello React Servcer Render!!!
        </div>
      </div>
    );
  }
}

module.exports = <Search />;

webpack.ssr.js

const path = require('path');
const glob = require('glob');

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

const setMPA = () => {
  const entry = {};
  const htmlWebpackPlugins = [];

  const entryFiles = glob.sync(path.join(__dirname, './src/pages/*/index-server.js'))

  entryFiles.map((entryFile) => {
    const match = entryFile.match(/src\/pages\/(.*)\/index-server\.js/);
    const pageName = match && match[1];

    entry[pageName] = entryFile;

    if (pageName) {
      htmlWebpackPlugins.push(
        new HtmlWebpackPlugin({
          template: path.resolve(__dirname, `src/pages/${pageName}/index.html`),
          filename: `${ pageName }.html`,
          chunks: [ pageName ],
          excludeChunks: ['node_modules'],
          inject: true,
          minify: {
            html5: true,
            collapseWhitespace: true,
            preserveLineBreaks: false,
            minifyCSS: true,
            minifyJS: true,
            removeComments: false
          }
        }),
      );
    }
  });

  return {
    entry,
    htmlWebpackPlugins
  }
}

const { entry, htmlWebpackPlugins }  = setMPA();

module.exports = {
  entry,
  output: {
    filename: '[name]-server.js',
    path: path.resolve(__dirname, 'dist'),
    libraryTarget: 'umd',
    globalObject: 'this',
    umdNamedDefine: true
  },
  mode: "production",
  // ....
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css'
    }),
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: require('cssnano')
    }),
    new CleanWebpackPlugin(),
  ].concat(htmlWebpackPlugins)
}

package.json

"scripts": {
  // ...
  "build:ssr": "webpack --config webpack.ssr.js"
},

打包存在的问题

浏览器的全局变量 (Node.js 中没有 document, window)

  • 组件适配:将不兼容的组件根据打包环境进⾏适配。
  • 请求适配:将 fetch 或者 ajax 发送请求的写法改成 isomorphic-fetch 或者 axios。

样式问题 (Node.js ⽆法解析 css)

  • ⽅案⼀:服务端打包通过 ignore-loader 忽略掉 CSS 的解析。
  • ⽅案⼆:将 style-loader 替换成 isomorphic-style-loader 。

如何解决样式不显示的问题

使用打包后的浏览器端 html 为模板。设置占位符,动态插入组件。

服务端请求会的数据,也可以使用占位符,嵌入到页面中。

server/index.js

const fs = require('fs');
const path = require('path');
const express = require('express');
const { renderToString } = require('react-dom/server');

const SEARCH_SSR = require('../dist/search-server');
const SEARCH_TEMPLATE = fs.readFileSync(path.resolve(__dirname, '../dist/search.html'), 'utf-8');
const DATA = require('./data.json');

const server = (port) => {
  const app = express();

  app.use(express.static('../dist'));

  app.get('/search', (req, res) => {
    const htmlStr = renderMarkUp(renderToString(SEARCH_SSR));
    res.status(200).send(htmlStr);
  });

  app.listen(port, () => {
    console.log('Server is running on port:', port);
  });
}

server(process.env.PORT || 3000);

const renderMarkUp = (str) => {
  const dataStr = JSON.stringify(DATA);
  return SEARCH_TEMPLATE.replace('<!--HTML_PLACEHOLDER-->', str)
                        .replace('<!--INITIAL_DATA_PLACEHOLDER-->', `<script>window._initial_data=${dataStr}</script>`);
}

data.json

{
  "code": 200,
  "msg": "查询文章列表成功",
  "articles": [
    {
      "articleId": "60373cba61495b7b5aada0db",
      "title": "消息队列和事件循环机制",
      "category": "BOM",
      "comments": 0,
      "pageviews": 6,
      "likes": 0,
      "desc": "从浏览器底层分析消息队列和事件循环机制。",
      "img": "https://data.yueluo.club/bom.png",
      "time": "2021-02-25",
      "author": "月落"
    },
    {
      "articleId": "60271e4cac51f0058d2ec33e",
      "title": "输入网址按下回车发生了什么?",
      "category": "网络",
      "comments": 0,
      "pageviews": 16,
      "likes": 0,
      "desc": "输入网址按下回车到底发生了什么?接下来为你揭晓。",
      "img": "https://data.yueluo.club/network.png",
      "time": "2021-02-13",
      "author": "月落"
    },
    {
      "articleId": "5fcc633afee352374cbbceb1",
      "title": "小程序双线程模型",
      "category": "微信小程序",
      "comments": 0,
      "pageviews": 31,
      "likes": 0,
      "desc": "腾讯是为了技术垄断才单独开发小程序体系?为什么小程序不用浏览器的线程模型?双线程模型和 JavaScript 单线程模型有什么区别?",
      "img": "https://data.yueluo.club/mini.png",
      "time": "2020-12-06",
      "author": "月落"
    }
    // ...
  ],
  "total": 76
}

src/search/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>

  <div id="app"><!--HTML_PLACEHOLDER--></div>

  <!--INITIAL_DATA_PLACEHOLDER-->

</body>
</html>

优化构建时命令行的显示日志

展示一大堆日志,很多并不需要开发者关注。

统计信息 stats

stats.png

webpack.prod.js

module.exports = {
  // ...
  stats: 'errors-only'
}

webpack.dev.js

module.exports = {
  // ...
  devServer: {
    contentBase: './dist',
    hot: true,
    stats: 'errors-only'
  }
}

这样输出也不是很友好,如果没有错误,啥都不展示。

如何优化命令行的构建日志

使用 firendly-errors-webpack-plugin。

  • success: 构建成功的⽇志提示
  • warning: 构建警告的⽇志提示
  • error: 构建报错的⽇志提示

stats 设置成 errors-only。

npm i friendly-errors-webpack-plugin -D

webpack.prod.js

module.exports = {
  entry,
  output: {
    filename: '[name][chunkhash:8].js',
    path: path.resolve(__dirname, 'dist')
  },
  mode: "production",
  // ...
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css'
    }),
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: require('cssnano')
    }),
    new CleanWebpackPlugin(),
    new FriendlyErrorsWebpaclPlugin(),
  ].concat(htmlWebpackPlugins),
  // ...
  stats: 'errors-only'
}

webpack.dev.js

module.exports = {
  entry,
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist')
  },
  mode: "development",
  // ...
  plugins: [
    new CleanWebpackPlugin(),
    new FriendlyErrorsWebpaclPlugin(),
  ].concat(htmlWebpackPlugins),
  devServer: {
    contentBase: './dist',
    hot: true,
    stats: 'errors-only'
  },
  devtool: 'source-map'
}

构建异常和中断处理

如何判断构建是否成功?

在 CI/CD 的 pipline 或者发布系统需要知道当前构建状态。

每次构建完成后输入 echo $? 获取错误码。

如果错误码不为 0 ,说明这个构建是失败的。

webpack4 之前的版本构建失败不会抛出错误码 (error code)。

Node.js 中的 process.exit 规范

  • 0 表示成功完成,回调函数中,err 为 null
  • ⾮ 0 表示执⾏失败,回调函数中,err 不为 null,err.code 就是传给 exit 的数字

如何主动捕获并处理构建错误?

compiler 在每次构建结束后会触发 done 这个 hook。

process.exit 主动处理构建报错。

webpack.prod.js

const path = require('path');
const glob = require('glob');

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');
const FriendlyErrorsWebpaclPlugin = require('friendly-errors-webpack-plugin');

// ...

module.exports = {
  entry,
  output: {
    filename: '[name]_[chunkhash:8].js',
    path: path.resolve(__dirname, 'dist')
  },
  mode: "production",
  // ...
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name]_[contenthash:8].css'
    }),
    new OptimizeCSSAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: require('cssnano')
    }),
    new CleanWebpackPlugin(),
    new FriendlyErrorsWebpaclPlugin(),
    function () {
      this.hooks.done.tap('done', (stats) => {
        if (
            stats.compilation.errors && 
            stats.compilation.errors.length && 
            process.argv.indexOf('--watch') == -1
          ) {
          console.log('build error');
          process.exit(1);
        }
      })
    }
  ].concat(htmlWebpackPlugins),
  // ...
  stats: 'errors-only'
}