前端模块化(webpack)

前言

前端模块化是一种开发管理规范,前端开发发展到现在,已经有很多成熟的构建工具可以帮助我们完成模块化的开发需求,但我们仍需要深入探究一下,这些模块化构建工具到底帮助我们做了哪些事情,这要我们才能更好的利用它们,从而提高我们的开发效率,本篇我们将以 webpack 为例,进行分析。

webpack 究竟解决了什么问题

如何在前端项目中更高效的管理和维护项目中的每一个资源

  • 模块化的演化进程

    • Stage 1 - 文件划分方式

      • 好处:提高了代码复用性,代码可抽离,可维护,方便模块间组合分解。
      • 弊端:所有 JS 文件共用全局作用域,会有命名冲突,污染全局环境;
        没有私有的模块空间,可以在外面任意修改。
      // a.js
      var a = "hello a";
      console.log(a);
      // b.js
      var a = "hello b";
      console.log(a);
      
      <!DOCTYPE html>
      <html lang="en">
        <head>
          <title>Document</title>
        </head>
        <body>
          <script src="a.js"></script>
          <script src="b.js"></script>
        </body>
      </html>
      
    • Stage 2 - 命名空间方式

      • 好处:解决了命名冲突问题。
      • 弊端:模块成员依然可以被修改。
      // module-a.js
      window.moduleA = {
        var a = 'hello a'
        console.log(a)
      }
      // module-b.js
      window.moduleB = {
        var a = 'hello b'
        console.log(b)
      }
      
      <!DOCTYPE html>
      <html lang="en">
        <head>
          <title>Document</title>
        </head>
        <body>
          <script src="module-a.js"></script>
          <script src="module-b.js"></script>
        </body>
      </html>
      
    • Stage 3 - IIFE 依赖参数

      • 好处:解决了命名冲突问题,全局作用域的问题,模块依赖
      • 弊端:模块加载顺序,文件数量过多
      // module-a.js
      ;(function(){
        window.moduleA = {
         var name = 'module-a';
         console.log(name)
        }
      })()
      // module-b.js
      ;(function(){
        window.moduleB = {
         var name = 'module-b';
         console.log(name)
        }
      })()
      
  • 由模块化产生的规范

    • CommonJS、AMD、 ESModules 规范

      // CommonJS 服务端规范(node环境)
      // lib.js
      var counter = 3;
      function incCounter() {
        counter++;
      }
      module.exports = {
        counter,
        incCounter,
      };
      // main.js
      var counter = require("./lib").counter;
      var incCounter = require("./lib").incCounter;
      
      console.log(counter); // 3
      incCounter();
      console.log(counter); // 3
      
      //AMD规范来源于 require.js
      // 使用步骤
      // 1. index.html中引入(require.js):https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js(cdn)
      // 2. script中设置,amd.js 是自己的代码文件
      // <script src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js" data-main="./amd.js"></script>
      // 3. 代码示例
      <!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">
        <script src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js" data-main="./amd.js"></script>
        <title>Document</title>
      </head>
      <body>
      </body>
      </html>
      
      // amd.js
      requirejs.config({
        baseUrl: './',
        paths: {
          app: './app'
        }
      });
      requirejs(['app/main']);
      
      // app/main.js
      define(function (require) {
        var messages = require('./messages');
        console.log(messages.getHello());
      });
      
      // app/messages.js
      define(function () {
        return {
            getHello: function () {
              return 'Hello World';
            }
        };
      });
      
      // ESModules 浏览器环境
      <!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>
        <script type="module" src="./app.js"></script>
      </body>
      </html>
      
      // app.js
      import { name, age } from './module.js'
      console.log(name, age);
      
      // module.js
      const name = 'xinmin'
      const age = 18
      
      export { name, age }
      
    • 总结:

      我们所使用的 ES Modules 模块系统本身就存在环境兼容问题。尽管现如今主流浏览器的最新版本都支持这一特性,但是目前还无法保证用户的浏览器使用情况。所以我们还需要解决兼容问题。随着前端业务复杂度的增加,开发过程中,模块化是必须的,所以我们需要引入工具来解决模块化所带来的兼容性问题。因此,各类如 webpack、gulp、vite 等打包工具就产生了。

      ES Modules 采用的是编译时就会确定模块依赖关系的方式。

      CommonJS 的模块规范中,Node 在对 JS 文件进行编译的过程中,会对文件中的内容进行头尾包装,在头部添加(function(export, require, modules, __filename, __dirname){\nxxxxxx\n})

  • 更为理想的方式

    1. 在页面中引入一个 js 入口文件,其余用到的模块通过代码控制,按需加载
    2. 同时在编码代码的过程中有着相应的约束规范保证所有的开发者实现一致
  • 引申出两点需求

    1. 一个统一的模块化标准规范
    2. 一个可以自动加载模块的基础库

如何使用 webpack 实现模块化打包

本质上,webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。

  • 核心概念

    入口(entery):指示 webpack 应该使用哪个模块来作为构建内部依赖图的开始,可以配置单入口或者多入口

    // 单个入口(简单)写法
    const config = {
      entry: "./path/to/my/entry/file.js",
    };
    // 单个入口,对象写法
    const config = {
      entry: {
        main: "./path/to/my/entry/file.js",
      },
    };
    // 多页面应用
    const config = {
      entry: {
        pageOne: "./src/pageOne/index.js",
        pageTwo: "./src/pageTwo/index.js",
        pageThree: "./src/pageThree/index.js",
      },
    };
    

    输出(output):指定打包输出文件路径与名称

    // 基础使用
    const config = {
      output: {
        filename: 'bundle.js',
        path: '/home/proj/public/assets'
      }
    };
    // 多入口起点(使用占位符)
    const config = {
      entry: {
        app: './src/app.js',
        search: './src/search.js'
      },
      output: {
        filename: '[name].js',
        path: __dirname + '/dist'
      }
    }
    // 使用cdn和资源hash
    output: {
      path: "/home/proj/cdn/assets/[hash]",
      publicPath: "http://cdn.example.com/assets/[hash]/"
    }
    

    Module:模块,在 webpack 里一切皆模块,一个模块对应着一个文件。webpack 会从配置的 Entry 开始递归找出所有依赖的模块。

    Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。

    loader:loader 用于对模块的源代码进行转换(安装相应处理的 loader)

    • 三种使用方式(配置(推荐)、内联、CLI)
    // 配置
    module.exports = {
      module: {
        rules: [
          {
            test: /\.css$/,
            use: [
              { loader: 'style-loader' },
              {
                loader: 'css-loader',
                options: {
                  modules: true
                }
              }
            ]
          },
          { test: /\.ts$/, use: 'ts-loader' }
        ]
      }
    };
    // 内联
    import Styles from 'style-loader!css-loader?modules!./styles.css';
    // CLI
    webpack --module-bind jade-loader --module-bind 'css=style-loader!css-loader'
    

    插件(plugin):插件目的在于解决 loader 无法实现的其他事

    const HtmlWebpackPlugin = require("html-webpack-plugin"); //通过 npm 安装
    const webpack = require("webpack"); //访问内置的插件
    const path = require("path");
    
    const config = {
      entry: "./path/to/my/entry/file.js",
      output: {
        filename: "my-first-webpack.bundle.js",
        path: path.resolve(__dirname, "dist"),
      },
      module: {
        rules: [
          {
            test: /\.(js|jsx)$/,
            use: "babel-loader",
          },
        ],
      },
      plugins: [
        new webpack.optimize.UglifyJsPlugin(),
        new HtmlWebpackPlugin({ template: "./src/index.html" }),
      ],
    };
    
    module.exports = config;
    

    模式(mode):根据开发和生产环境加载不同的插件进行处理

  • <b> webpack 的构建流程 </b>

    webpack 打包的执行流程

    • 在 webpack 函数中如传入配置信息,返回 compiler 实例
    • 调用 compiler 实例的 run 方法进行编译

    插件处理

    • 插件是在 complier 创建之后完成挂载的,但是挂在不意味着执行、
    • 某些插件是在整个流程的某些时间点上触发的,所以这种情况就要是使用到钩子 tapable
    • 插件其实就是一个具有 apply 函数的类

    处理入口

    • 从配置文件中读取 entry 的值,内部转化为对象进行处理

    新增属性

    • 整个打包结束之后,会产生出很多的内容,这些内容需要存储

    初始化编译

    • 定位入口文件的绝对路径
    • 统一路径分隔符
    • 调用自己的方法来实现编译

    loader 参与打包工作

    • 读取被打包模块的源文件
    • 使用 loader 来处理源文件(依赖的模块)
    • loader 就是一个函数,接受原始数据,处理之后返回给 webpack 继续使用
    • 以降序的方式的方式来执行 loader

    模块编译实现(单模块)

    • webpack 找到 a.js 模块之后,就是对它进行处理,处理之后的内容就是一个键值对
    • 键:./src/a.js,而值就是 a.js 的源代码
      1. 获取被打包模块的模块 id

    ast 语法树,实现 ast 遍历(webpack 中解析使用 acorn)

    • @babel/parser 解析器,将源代码转化成 ast 语法树
    • @babel/traverse 实现 ast 语法树遍历
    • @babel/generator 将处理后 ast 转换成可执行的源代码
    • @babel/core 和 @babel/preset-env 将 AST 语法树转换为浏览器可执行代码
  • 实现一个简单的 loader

    const marked = require("marked");
    module.exports = (source) => {
      const html = marked.parse(source);
      const code = `module.exports = ${JSON.stringify(html)}`;
      // const code = `exports =${JSON.stringify(html)}`
      // const code = `export default = ${JSON.stringify(html)}`
      return code;
    };
    
  • 实现一个简单的 plugin

    // 去除开发环境打包中多余的注释
    class RemoveCommentsPlugin {
      apply(compiler) {
        compiler.hooks.emit.tap("RemoveCommentsPlugin", (compilation) => {
          // compilation 可以理解为此次打包的上下文
          for (const name in compilation.assets) {
            console.log("compilation", compilation.assets[name].source());
            if (name.endsWith("js")) {
              const contents = compilation.assets[name].source();
              const noComments = contents.replace(/\/\*{2,}\/\s?/g, "");
              compilation.assets[name] = {
                source: () => noComments,
                size: () => noComments.length,
              };
            }
          }
        });
      }
    }
    module.exports = RemoveCommentsPlugin;
    
  • 实现一个 min-pack

    const parser = require("@babel/parser");
    const traverse = require("@babel/traverse").default;
    const babel = require("@babel/core");
    const { SyncHook } = require("tapable");
    const fs = require("fs");
    const path = require("path");
    
    class Compiler {
      constructor(options) {
        this.options = options;
        // this.entries = new Set(); // 保存打包过程中的入口信息  webpack4中是数组
        this.modules = []; // 保存打包过程中出现的module信息
        // this.chunks = new Set(); // 保存代码块信息
        // this.files = new Set(); // 保存所有产出文件的名称
        this.assets = {}; // 资源清单
        this.context = this.options.context || process.cwd();
        this.hooks = {
          entryInit: new SyncHook(["compilation"]),
          beforeCompile: new SyncHook(),
          afterCompile: new SyncHook(),
          afterPlugins: new SyncHook(),
          emit: new SyncHook(),
          afterEmit: new SyncHook(),
        };
      }
    
      // 构建启动
      run() {
        // 执行 plugins
        // this.hooks.entryInit.call(this.assets);
    
        /// 1. 确定入口信息
        let entry = {};
        if (typeof this.options.entry === "string") {
          entry.main = this.options.entry;
        } else {
          entry = this.options.entry;
        }
    
        /// 2. 确定入口文件的绝对路径
        for (let entryName in entry) {
          // TODO: 调用自定义的方法来实现具体的编译过程,得到结果
          const entryModule = this.build(entry[entryName]);
    
          // 添加到module中
          this.modules.push(entryModule);
        }
    
        /// 3. 递归调用获取所有依赖内容
        this.modules.forEach(({ dependecies }) => {
          if (Object.keys(dependecies).length > 0) {
            Object.keys(dependecies).forEach((deps) => {
              this.modules.push(this.build(dependecies[deps]));
            });
          }
        });
    
        /// 4. 生成依赖关系图
        const dependencyGraph = this.modules.reduce(
          (graph, item) => ({
            ...graph,
            [item.filename]: {
              dependecies: item.dependecies,
              code: item.code,
            },
          }),
          {}
        );
        // console.log('dependencyGraph', dependencyGraph);
        this.assets = dependencyGraph;
        // console.log('this.assets', this.assets)
        // 执行 plugins
        this.hooks.entryInit.call(this.assets);
        /// 5. 生成 bundle
        this.generate(dependencyGraph);
      }
    
      // 获取ast
      getAst(filePath) {
        let code = fs.readFileSync(filePath, "utf-8");
        let loaders = [];
        // console.log('filePath', filePath);
        const rules = this.options.module?.rules;
        for (let i = 0; i < rules?.length; i++) {
          // 从众多的 rules 当中找到 匹配的文件的配置
          if (rules[i].test.test(filePath)) {
            loaders = [...loaders, ...rules[i].use];
          }
        }
        //* 调用loader
        for (let i = loaders.length - 1; i >= 0; i--) {
          let abPath = loaders?.[i];
          if (loaders[i]?.includes("./")) {
            abPath = path.resolve(this.context, loaders[i]);
          }
          code = require(abPath)(code);
        }
        const ast = parser.parse(code, { sourceType: "module" });
        return ast;
      }
      // 获取依赖关系
      getDependecies(ast, fileName) {
        const dependencies = {};
        traverse(ast, {
          CallExpression: (nodePath) => {
            const dirname = path.dirname(fileName);
            const node = nodePath.node;
            // 在ast中找到了require
            if (node.callee.name === "require") {
              const rPath = node.arguments[0].value;
              // 获取相对路径
              const aPath = path.resolve(dirname, rPath);
              dependencies[rPath] = aPath;
            }
          },
          // 在ast中找到import
          ImportDeclaration: (nodePath) => {
            const dirname = path.dirname(fileName);
            const rPath = nodePath.node.source.value;
            const aPath = path.resolve(dirname, rPath);
            dependencies[rPath] = aPath;
          },
        });
        return dependencies;
      }
    
      // 编译ast
      getTranslateCode(ast) {
        const { code } = babel.transformFromAst(ast, null, {
          presets: ["@babel/preset-env"],
        });
        return code;
      }
    
      // 编译
      build(filename) {
        const ast = this.getAst(filename);
        const dependecies = this.getDependecies(ast, filename);
        const code = this.getTranslateCode(ast);
        return {
          filename,
          dependecies,
          code,
        };
      }
    
      // 生成
      generate(code) {
        const filePath = path.join(this.options.output.path, "main.js");
        const bundle = `(function(graph){
          function require(moduleId){ 
            function localRequire(relativePath){
              return require(graph[moduleId].dependecies[relativePath])
            }
            var exports = {};
            (function(require,exports,code){
              eval(code)
            })(localRequire,exports,graph[moduleId]?.code);
            return exports;
          }
          require('${this.options.entry}')
        })(${JSON.stringify(code)})`;
    
        // console.log('filePath', filePath, bundle);
        fs.writeFileSync(filePath, bundle, "utf-8");
        // try {
        //   fs.writeFileSync(filePath, bundle, "utf-8")
        // } catch (e) {
        //   fs.mkdirSync(path.dirname(filePath))
        //   fs.writeFileSync(filePath, bundle, "utf-8")
        // }
      }
    }
    
    module.exports = Compiler;
    
  • 配合 min-pack 实现 css-loader

    module.exports = (source) => {
      // console.log('source', source);
      let str = `
      let style = document.createElement("style");
      style.innerHTML = ${JSON.stringify(source)};
      document.head.appendChild(style);
      `;
      return str;
    };
    
  • 配合 min-pack 实现 DemoPlugin

    class DemoPlugin {
      apply(compiler) {
        compiler.hooks.entryInit.tap("DemoPlugin", (compilation) => {
          if (
            Array.isArray(Object.keys(compilation)) &&
            Object.keys(compilation).length > 0
          ) {
            for (let k in compilation) {
              if (k.endsWith("b.js")) {
                compilation[k].code =
                  compilation[k].code + `console.log('min-webpack v1.1')`;
    
                console.log(" compilation[k].code");
              }
            }
          }
          // compilation 可以理解为此次打包的上下文
          return compilation;
        });
      }
    }
    
    module.exports = DemoPlugin;
    

webpack 的性能优化

优化方向:构建性能、传输性能、运行性能

  • 构建性能

    • 优化开发体验

      1. 自动更新:watch,webpack-dev-server,webpack-dev-middleware
      2. 热更新:@pmmmwh/react-refresh-webpack-plugin react-refresh
    • 加快编译速度

      1. 使用最新 node,npm,webpack 版本,有助于提升性能
      2. cache:提升二次构建速度,缓存 webpack 模版和 chunk(webpack5)
      3. 减少非必要 loader、plugins 的使用,都会增加编译时间
      4. 使用 loader 时,配置 rule.exclude:排除模块范围,减少 loader 的应用范围
      5. 使用 webpack 资源模块代替原来的 assets loader(如:file-loader/url-loader)(webpack5)
      6. 优化 resolve 配置(配置别名,根据项目中的文件类型定义 extensions,加快解析速度。(如:resolve: { extensions: ['.tsx', '.ts', '.js'] }
      7. 多进程(如 babel-loader 构建时间较长,使用 thread-loader 可将 loader 放在独立的 work 池中运行,仅对非常耗时的 loader 使用)
      8. 其他:区分环境([fullhash]/[chunkhash]/[contenthash])devtool 设置
  • 传输性能

    • 减小打包体积

      1. js 压缩(webpack5 开箱即用,默认开启多进程与缓存:terser-webpack- plugin)
      2. css 压缩( optimize-css-assets-webpack-plugin)
      3. splitChunks
        3.1 新的 chunk 可以被共享,或者模块来自于 node_modules 文件夹
        3.2 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
        3.3 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
        当加载初始化页面时,并发请求的最大数量小于或等于 30
      4. css 文件分离(mini-css-extract-plugin)
      5.      Tree Shaking(摇树)通过配置:sideEffects,只能清除无副作用的引用,有副作用需要通过优化引用的方式。(css Tree Shaking:purgecss-webpack-plugin)
        
      6. CDN 加速:将字体,图片等静态资源上传 CDN
  • 运行性能

    • 加快加载速度

      1. import 动态导入
      2. 浏览器缓存,创建 hash id
      3. moduleIds: "deterministic", 公共包 hash 不因为新的依赖而改变
      4. 静态资源使用 cdn 缓存
  • 总结

    小型项目,添加过多优化配置,反而会因为添加额外的 loader 与 plugin 增加构建时间
    构建阶段,使用 cache,可大大加快二次构建速度
    减少打包体积,作用最大的是压缩代码,分离重复代码,Tree Shaking 作用也比较大
    加载速度:按需加载,浏览器缓存,CDN 缓存效果都不错

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容