前端工程化(三)

webpack 打包

模块化开发为我们解决了很多问题,使得代码组织管理非常的方便,但是又带来了新的问题,ES Module 存在环境兼容问题,划分的文件太多,就会导致网络请求频繁,不能保证所有资源的模块化

如果能我们享受模块化带来的开发优势,又能不必担心生产环境的存在这些问题,于是就有了 webpack, rollup, Parcel 等工具
webpack 模块化不等于 js ES modele 模块,相对来讲是前端的模块化处理方案,更加宏观

  • 快速上手
$ yarn init --yes
$ yarn add webpack webpack-cli -D
$ yarn webpack --version
$ yarn webpack // 默认打包src/index.js // 最终存放到dist/main.js
  • webpack 配置文件

在项目根目录添加 webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/main.js', // 入口文件
  output: {
    filename: 'bundle.js', // 输出文件名
    path: path.join(__dirname, 'output'), // 输出文件路径(绝对路径)
  },
};
  • 工作模式

webpack4 新增了工作模式的用法,大大简化了配置的复杂程度;三种工作模式 mode: production development none

$ webpack --mode none
$ webpack --mode production // 默认模式
$ webpack --mode development

或者采用配置的方式

const path = require('path');

module.exports = {
  // 这个属性有三种取值,分别是 production、development 和 none。
  // 1. 生产模式下,Webpack 会自动优化打包结果;
  // 2. 开发模式下,Webpack 会自动优化打包速度,添加一些调试过程中的辅助;
  // 3. None 模式下,Webpack 就是运行最原始的打包,不做任何额外处理;
  mode: 'development',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
};
  • 资源模块加载

    webpack 内部的 loader 只能处理 js 文件,其他文件我们需要配置对应的 loader 才可以完成打包,否则会报错。

const path = require('path');

module.exports = {
  mode: 'none',
  entry: './src/main.css',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /.css$/,
        // css-loader作用就是将css代码转化为js模块
        // style-loader作用就是将cssloader转化的结果追加到页面
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};
  • 导入资源模块
    入口文件为 js 文件,根据代码的需要动态导入其他资源,由 javascript 驱动整个前端应用
const path = require('path');

module.exports = {
  mode: 'none',
  entry: './src/main.css',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

// main.js
import './main.css';
  • 文件资源加载器

    • file-loader
      经过 file-loader 处理后,将文件资源放到我们打包目录的根目录。返回文件资源的访问路径,通过 import 就可以拿到文件资源的路径。webpack 默认认为文件资源放在网站的根目录下
      会发起文件请求
const path = require('path');

module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
    publicPath: 'dist/',
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /.png$/,
        use: 'file-loader',
      },
    ],
  },
};
// main.js
import createHeading from './heading.js';
import './main.css';
import iconURL from './icon.png';
// 经过file-loader处理后,将图片放到我们打包目录的根目录。返回图片的访问路径,通过import就可以拿到图片的路径。webpack默认认为图片放在网站的根目录下
const heading = createHeading();

document.body.append(heading);

const img = new Image();
img.src = iconURL;

document.body.append(img);
  • url-loader
    将文件资源转化为 Data Url, 最终返回这个 Data Url,不单独生成资源文件,直接嵌入到 bundle.js 中
    当资源文件过大时,导致 base64 边长,打包的 bundle.js 体积过大

    • Data URLs
      直接表示文件内容,使用这种 Url 不会发起 Http 请求

      data:[][;base64],
      
      // 协议 + 媒体类型以及编码+ 文件内容(图片会被转化为base64)
      

最佳实践:小文件使用 Data URLs, 减少请求次数。大文件单独提取,避免 bundle.js 过大,加载时间过长

module: {
  rules: [
    {
      test: /.css$/,
      use: ['style-loader', 'css-loader'],
    },
    {
      test: /.png$/,
      use: {
        // 必须同时安装file-loader,当超过limit设置的值,url-loader会自动让file-loader处理
        loader: 'url-loader',
        options: {
          limit: 10 * 1024, // 10 KB
        },
      },
    },
  ];
}
  • 常用 loader 分类

    • 编译转化类型

    • 文件操作类型

    • 代码质量检查

  • 处理 ES6+新特性

    webpack 只是打包工具 默认处理代码中的 export 和 import,但对其他 ES6+新特性不做处理,需要 babel-loader

$ yarn add babel-loader @babel/core @babel/preset-env -D
// babel 只是一个js的转换平台。基于平台通过不同的插件实现转化

{
  "test": /.js$/,
  "use": {
    "loader": "babel-loader",
    "options": {
      "presets": ["@babel/preset-env"]
    }
  }
}
  • 模块加载方式
    webpack 兼容多种标准的模块加载方式

    • ES Module
    • CommonJs
    • AMD
    • import('XXX.css')
    • @import ()
    • @import url()
    • html 中的 img 的 src 属性
    • background 属性的 url
    • a 标签的 herf 属性
      ...
  • webpack 核心工作原理

    • 首先设置入口文件,webpack 会根据配置找到入口文件(如果不设置默认 src 下面的 index.js 文件)作为我们的打包入口

    • 根据代码中出现的 import 或者 require 解析推断出这个文件所依赖的其他资源模块

    • 然后分别延伸解析每一个资源模块对应的依赖,形成一个整个项目当中所有用的资源文件的依赖树

    • 然后递归这个依赖树,找到每个节点对应的资源文件,根据配置文件的 rules 属性找到当前模块的加载器(loader),然后交给加载器加载这个模块

    • 最终将执行完成的结果放到 output 对应的 bundle.js 中

    • 在整个过程中,会通过 webpack 提供的钩子函数(生命周期函数)加载对应的任务。这个任务我们也成 plugins

  • webpack 开发一个 loader
    原则: 对同一文件所用到的 loader 执行完成后, 最终必须返回 javascropt 代码,也就是处理当前资源的最后的 loader 必须是返回 javascript 代码

const path = require('path');

module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'dist'),
    publicPath: 'dist/',
  },
  module: {
    rules: [
      {
        test: /.md$/,
        // 将 md 转化为 html
        use: ['html-loader', './markdown-loader'],
      },
    ],
  },
};
// main.js
import about from './about.md';

console.log(about);
// markdown-loader.js
const marked = require('marked');

module.exports = source => {
  // source为加载进来的资源内容

  const html = marked(source);
  // 如果不交给下个loader处理

  // return `module.exports = "${html}"`
  // return `export default ${JSON.stringify(html)}`
  // 如果交给下个loader处理
  // 返回 html 字符串交给下一个 loader 处理
  return html;
};
  • 常用插件 Plugin

    clean-webpack-plugin
    每次打包前先清除 webpack 输出目录

    HtmlWebpackPlugin
    每次打包的文件自动生成 html 文件,自动引入打包结果

plugins: [
  new webpack.ProgressPlugin(),
  new CleanWebpackPlugin(),
  // 不额外添加模板的使用
  new HtmlWebpackPlugin({
    title: 'glh', // 设置标题
    meta: {
      // 设置meta标签
      viewport: 'width=device-width',
    },
    // ...
  }),
];
// 添加模板,让HtmlWebpackPlugin根据模板生成
new HtmlWebpackPlugin({
  title: 'glh', // 设置标题
  meta: {
    // 设置meta标签
    viewport: 'width=device-width',
  },
  template: './public/index.html',
  templateParameters: {
    // 自定义变量
    BASE_URL: './',
  },
  // ...
});



  
    
    
    
    
    <%= htmlWebpackPlugin.options.title %>
  

  
    
    
// 用于生成index.html
new HtmlWebpackPlugin({
  template: './public/index.html',
  // ...
});
// 用于生成about.html
new HtmlWebpackPlugin({
  filename: 'about.html',
  // ...
});

copy-webpack-plugin
对一些公共资源文件直接复制到打包目录中。比如 public/favicon.ico

new CopyWebpackPlugin({
  patterns: [{ from: 'public/favicon.ico', to: '.' }],
});

我们一般在使用插件的时候掌握一些经常用的就可以。后面根据需求再去提炼关键词,搜索自己想用的插件,当然也可以自己写。插件的约定名称一般都是 XXX-webpack-plugin,比如我们想要压缩图片就可以找 imagemin-webpack-plugin

  • 实现一个自定义 plugin

首先要明白:

  • Plugin 其实就是通过在生命周期的钩子中挂载函数实现扩展。类似于我们 React 中的声明周期。
    webpack 在工作的过程中给每一个环节都埋下了钩子,我们只需要在对应的钩子下挂载任务就可以轻松的扩展 webpack 的能力

自定义的 Plugin 其实就是一个函数,或者包含 apply 的方法的对象
apply 方法接受一个 compiler 对象参数,这个参数包含我们整个构建过程中的所有配置信息,通过这个对象我们可以注册钩子函数,通过 tap 方法注册任务
tap 方法又接受两个参数,一个是插件名称,一个是当前次打包执行的上下文

  • 和 loader 区别:loader 是专注实现资源模块加载转化
    Plugin 是解决处理资源加载转化之外的的一些自动化工作
    相比于 Loader,Plugin 的能力范围更宽
    因为 Loader 只是在加载模块的范围工作,而插件的工作范围可以触及到 webpack 的每一个环节
class MyPlugin {
  apply(compiler) {
    console.log('MyPlugin 启动');
    // 这里要做的事情就是在emit钩子上挂载一个任务,这个任务帮我们去除打包后没有必要的注释(mode=none情况下)其他钩子可参考官网
    compiler.hooks.emit.tap('MyPlugin', compilation => {
      // compilation => 可以理解为此次打包的上下文
      for (const name in compilation.assets) {
        // console.log(name)
        // console.log(compilation.assets[name].source())
        if (name.endsWith('.js')) {
          const contents = compilation.assets[name].source();
          const withoutComments = contents.replace(/\/\*\*+\*\//g, '');
          compilation.assets[name] = {
            source: () => withoutComments,
            size: () => withoutComments.length,
          };
        }
      }
    });
  }
}
plugins: [new MyPlugin()];
  • 增强 webpack 的开发体验
// 不使用Webpack Dev Server情况下,自动监听打包文件的变化
$ yarn webpack --watch
$ http-server -c-1 dist //or $ browser-sync dist --file  "**/*"

以上方式效率太低,文件不断的被读写操作,有待优化

  • Webpack Dev Server

    编写源代码=> webpack 打包=> 运行应用=> 刷新浏览器
    我们可以借助 Webpack Dev Server 来提升开发体验,更接近生产环境的运行状态,同时也可以设置 proxy,对于错误我们可以使用 souceMap 来快速定位源代码问题

$ yarn add webpack-dev-server -D
$ yarm webpack-dev-server --open

webpack-dev-server 并不会将打包结果放到磁盘中,暂时存放到内存中,从临时内存中读取内容发送给浏览器,从而大大提高了效率

  • webpackDevServer 的静态资源访问
devServer: {
  contentBase: './public', //也可以指定数组标识多个目录
}
  • 代理 proxy
    代理方式适用于后端没有配置 cors 的情况
    如果我们的项目最终上线前后端代码符合同源策略,也就没必要设置 cors 了,这个时候可以通过本地服务器配置代理的方式实现跨域请求
  devServer: {
    proxy: {
      '/api': {
        // http://localhost:8080/api/users -> https://api.github.com/api/users
        target: 'https://api.github.com',
        // http://localhost:8080/api/users -> https://api.github.com/users
        pathRewrite: {
          '^/api': '' // 根据后端接口文件路劲因情况而定,这里只是用github举例说明
        },
        // 不能使用 localhost:8080 作为请求 GitHub 的主机名
        changeOrigin: true
      }
    }
  }
// main.js;
// 跨域请求,虽然 GitHub 支持 CORS,但是不是每个服务端都应该支持。
// fetch('https://api.github.com/users')
fetch('/api/users') // http://localhost:8080/api/users
  .then(res => res.json())
  .then(data => {
    data.forEach(item => {
      const li = document.createElement('li');
      li.textContent = item.login;
      ul.append(li);
    });
  });
  • sourceMap

    由于编写的代码和运行的代码不一致,sourceMap 帮我们定位源代码错误
    webpack 提供了 12 中 sourceMap 方式。每种方式的效果和效率不同,效果最好的,效率最差,效果最差的,效率最高,因此我们只需要实际开发中符合需求的最佳实践
    cheap: 定位到行,不定位列
    eval: 定位到文件
    module: 定位 loader 处理前的源代码
    inline: 把 sourcemap 嵌入到打包文件中,不额外生成对应的.map 文件
    hidden: 会有错误信息,但是不是源文件。开发第三方包的时候可以用
    nosources: 看不到源代码,但是会有行列信息,保护在生产环境中源代码不被暴露

devtool: // 开发环境  'cheap-module-eval-source-map',
  // 生产环境 'none',
  // 如果对自己上线代码没有信心 'nosources-source-map'
const HtmlWebpackPlugin = require('html-webpack-plugin');

const allModes = [
  'eval',
  'cheap-eval-source-map',
  'cheap-module-eval-source-map',
  'eval-source-map',
  'cheap-source-map',
  'cheap-module-source-map',
  'inline-cheap-source-map',
  'inline-cheap-module-source-map',
  'source-map',
  'inline-source-map',
  'hidden-source-map',
  'nosources-source-map',
];

module.exports = allModes.map(item => {
  return {
    devtool: item,
    mode: 'none',
    entry: './src/main.js',
    output: {
      filename: `js/${item}.js`,
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env'],
            },
          },
        },
      ],
    },
    plugins: [
      new HtmlWebpackPlugin({
        filename: `${item}.html`,
      }),
    ],
  };
});
  • 热更新(HMR)代替自动刷新
    自动刷新导致页面状态丢失
    热更新就是在页面不跟新的情况下,只将修改的模块实时替换到应用中
$ yarn webpack-dev-server --hot
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: './src/main.js',
  output: {
    filename: 'js/bundle.js',
  },
  devtool: 'source-map',
  devServer: {
    hot: true,
    // hotOnly: true // 只使用 HMR,不会 fallback 到 live reloading
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
    // ...
  ],
};

默认的 HMR 开启后还需要我们手动去处理热更新的逻辑。当然在 css 文件中由于 cssloader 中已经帮我们处理了,所以我们可以看到修改 css 可以出发热跟新
编写的 js 模块由于代码太过灵活,如果没有框架的约束,wabpack 很难实现通用的热更新

  • HMR API
import createEditor from './editor';
import background from './better.png';
import './global.css';

const editor = createEditor();
document.body.appendChild(editor);

const img = new Image();
img.src = background;
document.body.appendChild(img);

// ============ 以下用于处理 HMR,与业务代码无关 ============

// console.log(createEditor)

if (module.hot) {
  let lastEditor = editor;
  // 处理js模块的热更新
  module.hot.accept('./editor', () => {
    // console.log('editor 模块更新了,需要这里手动处理热替换逻辑')
    // console.log(createEditor)

    const value = lastEditor.innerHTML;
    document.body.removeChild(lastEditor);
    const newEditor = createEditor();
    // 解决文本框状态丢失
    newEditor.innerHTML = value;
    document.body.appendChild(newEditor);
    lastEditor = newEditor;
  });
  // 处理img热更新
  module.hot.accept('./better.png', () => {
    img.src = background;
    console.log(background);
  });
}

以上例子 只是说明 webpack 没办法提供通用方案。实现一个热更新原理就是利用 module.hot,HotModuleReplacementPluginApi 提供的这个。大部分框架中都集成了 HMR。

  • 不同环境的配置文件
// 函数方式配置
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = (env, argv) => {
  const config = {
    // ...
  };

  if (env === 'production') {
    config.mode = 'production';
    config.devtool = false;
    config.plugins = [
      ...config.plugins,
      new CleanWebpackPlugin(),
      new CopyWebpackPlugin(['public']),
    ];
  }

  return config;
};

文件划分的配置

// webpack.common.js

module.exports = {};

// webpack.dev.js
const common = require('./webpack.common');
const merge = require('webpack-merge'); // 安装webpack-merge合并配置
module.exports = merge(common, {
  mode: 'development',
  devtool: 'cheap-eval-module-source-map',
  devServer: {
    hot: true,
    contentBase: 'public',
  },
  plugins: [new webpack.HotModuleReplacementPlugin()],
});

// webpack.prod.js
const merge = require('webpack-merge');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const common = require('./webpack.common');

module.exports = merge(common, {
  mode: 'production',
  plugins: [new CleanWebpackPlugin(), new CopyWebpackPlugin(['public'])],
});
$ yarn webpack --config webpack.prod.js
$ yarn webpack-dev-server --config webpack.dev.js
  • DefinePlugin
    为代码注入全局成员
    默认注入 process.evn.NODE_ENV 常量
plugins: [
  new webpack.DefinePlugin({
    // 值要求的是一个代码片段
    API_BASE_URL: JSON.stringify('https://api.example.com'),
  }),
];
  • Tree-shaking
    将未引用代码去除掉 生产环境下自动开启
    在其他模式下开启需要:
 optimization: {
    // 模块只导出被使用的成员
    usedExports: true,
    // 尽可能合并每一个模块到一个函数中
    concatenateModules: true, // scope Hoisting
    // 压缩输出结果
    minimize: true
  }
  • Tree-shaking && babel
    由于 Tree-shaking 是基于 ESModule 实现的,但是 旧版本 babel 中如果用到 preset-env 的插件集合的时候会默认开启转化 ESModule 的导入导出语法为 Commonjs 的规范。所以导致 Tree-shaking 失效,新版本已默认关闭

  • sideEffects 新特新
    标识代码是否有副作用,为 Tree-shaking 提供更大的压缩空间

// webpack.config.js
optimization: {
  sideEffects: true; // 开启sideEffects功能
}
// package.json
"siedEffects": false // 标识代码是否有副作用

副作用需要我们手动添加并且谨慎使用,一般用在开发第三方包中,当我们的代码有副作用,但是却配置了以上两个属性,就会导致程序报错。

// package.json 配置有副作用的文件,这样webpack在打包的过程中就不会忽略这些
"siedEffects" :[
  "./src/extend.js",
  "*/css"
]
  • Code Splitting
    打包成一个文件导致体积过大,加载时间过长。
    应用启动的首屏并不是所有模块都工作的
    所以我们需要分包,按需加载

    • 多入口打包
      适用于多页面应用
      entry: {
        index: './src/index.js',
        album: './src/album.js'
      },
      output: {
        filename: '[name].bundle.js'
      },
      optimization: {
        splitChunks: {
        // 自动提取所有公共模块到单独 bundle
          chunks: 'all'
        }
      },
      plugins: [
        new HtmlWebpackPlugin({
          title: '首页',
          template: './src/index.html',
          filename: 'index.html',
          chunks: ['index'] //  对不同的页面指定不同的打包js文件
        }),
        new HtmlWebpackPlugin({
          title: '其他页面',
          template: './src/album.html',
          filename: 'album.html',
          chunks: ['album']
        })
      ]
    
    • 动态导入 import()
      适用于单页面应用
      在 react 或者 vue 中一般都是通过路由映射组件实现动态加载
      webpack 会根据 import()把对应的模块拆分到不同的输出文件,根据加载的需要执行不同的 js 文件
    // import posts from './posts/posts'
    // import album from './album/album'
    const render = () => {
    const hash = window.location.hash || '#posts'
    
    const mainElement = document.querySelector('.main')
    
    mainElement.innerHTML = ''
    
    if (hash === '#posts') {
        // mainElement.appendChild(posts())
        import(/_ webpackChunkName: 'components' _/'./posts/posts').then(({ default: posts }) => {
        mainElement.appendChild(posts())
      })
    } else if (hash === '#album') {
        // mainElement.appendChild(album())
        import(/_ webpackChunkName: 'components' _/'./album/album').then(({ default: album }) => {
        mainElement.appendChild(album())
        })
      }
    }
    
    render()
    
    window.addEventListener('hashchange', render)
    
  • 魔法注释
    通过在 import 语句中加注释的方式为 webpack 提供打包后的名称如果设置一样则打包到一个文件

if (hash === '#posts') {
  // mainElement.appendChild(posts())
  import(/* webpackChunkName: 'components' */ './posts/posts').then(
    ({ default: posts }) => {
      mainElement.appendChild(posts());
    }
  );
} else if (hash === '#album') {
  // mainElement.appendChild(album())
  import(/* webpackChunkName: 'components' */ './album/album').then(
    ({ default: album }) => {
      mainElement.appendChild(album());
    }
  );
}
  • MiniCssExtractPlugin
    提取 css 到单个文件
    需要考虑 css 大小,如果很少写 css 那么还是采用 stypeLoader 注入到页面的 style 标签中
 module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          // 'style-loader', // 将样式通过 style 标签注入
          MiniCssExtractPlugin.loader, // 将样式通过Link标签方式注入
          'css-loader'
        ]
      }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin()
  ]
  • webpack 内部提供的生产环境的压缩只是针对 JS 代码的。如果想要压缩其他形式资源,需要单独安装对应的插件
optimization: {
  minimize: [
  // 要使用其他压缩,这里要把默认的js压缩的插件也安装进来,是因为webpack会覆盖了 optimization原有的默认配置
  // 这里配置的压缩都只会在生产环境起作用,符合我们的预期,不用再去放到webpack.prod.js或者根据环境变量判断
  new TreserWebpackPlugin(),
  // 这里以压缩css为例,其他的参见官网
  new OptimizeCssAssetsWebpackPlugin()]
}
  • 输出文件名称 Hash
    一般我们部署前端资源文件的时候,都会开启静态资源缓存,避免每次都请求资源,整体应用的响应速度就会大幅度提升,不过也会有问题,当我们缓存时间设置过长,我们的应用更新过后,浏览器并不会及时更新。这就需要我们在生产环境中需要给文件添加 Hash 值,全新的文件名就是全新的请求,也就避免了上述问题
    hash: 只要内容修改,所有文件 hash 都会跟新
    contenthash: 文件级别的 hash,当前修改的文件以及被引用的文件 hash 会被动跟新
    chunk: 当文件内容改变,只修改当前同类的 hash 值
  output: {
    filename: '[name]-[contenthash:8].bundle.js'
  },

还有一些其他的配置项比如 preformance target externals resolve other option 我们只需要查阅官方文档即可,另外还需要多理解 manifest 和 runtime 这样的 webpack 概念

你可能感兴趣的:(前端工程化(三))