6. webpack-zhu

394 阅读28分钟
  • 一般来说

  • index.html 放在自己的服务器上 不开启缓存 方便更新

  • index.html 引用的静态文件 js css 要加 hash 值 存放在 cdn 上 进行长期缓存

  • tree-shaking

    import {join} from 'lodash' 会打包整个 lodash import join from 'lodash/join' 只打包 join 模块 如果就想import {join} from 'lodash'这样引入,又想实现按需加载,需要使用 babel-plugin-import

loader

  • loader 是有分类的
  • pre 前置
  • normal 正常
  • inline 内联
  • post 后置
  • pre => normal => inline => post

关于模式 mode

development 与 prodution

  • 设置 process.env.NODE_ENV 的值
  • development 模式 -> development
  • production 模式 -> production

区分环境

  • 四种方式
  1. --mode 用来设置模块内的 process.env.NODE_ENV
  2. --env 用来设置 webpack 配置文件的函数参数
  3. cross-env 用来设置 node 环境的 process.env.NODE_ENV
  4. DefinePlugin 用来设置模块内的全局变量
  • 只有 mode 会影响 webpack 插件的启用, env 不会
  • --mode: 只能在模块内部拿到环境变量
    • 可以在模块内通过process.env.NODE_ENV获取当前环境变量, 无法在 webpack 配置文件中获取此变量
    • 在 webpack 配置文件导出对象中配置 mode
    • 可以在模块内部拿到
    • 但是命令行里的配置优先级比较高
"script": {
    "build": "webpack --mode=production",
    "start": "webpack serve --mode=development"
}
// webpack 5 中执行webpack serve 会默认使用webpack-dev-server服务
  • --env: 可以在 webpack 配置文件中拿到环境变量

    • 这样配置是给 webpack 配置文件了
    • 这种配置在模块内部拿不到环境变量,在 webpack 配置文件中也拿不到环境变量
    • 但是当 webpack 配置文件导出的是一个函数的时候,可以在参数中拿到环境变量
"script": {
    "build": "webpack --env=production",
    "start": "webpack serve --env=development"
}
// webpacck.config.js
module.exports = (env, argv) => {
    return {
        entry,
        output,
        module,
        plugins
    }
}
  • DefinePlugin 定义全局变量的插件

    • 设置全局变量(不是 window)所有模块都能读取到该变量的值
    • 可以在任意模块内通过process.env.NODE_ENV获取当前的环境变量
    • 但是无法在 node 环境(webpack 配置文件中)下获取当前的环境变量
    • 因为--mode 只能在模块内,--env 只能在 webpack 配置的函数参数中,都只能满足其中一个方面
    • 于是利用 DefinePlugin,在--env 的前提下,使环境变量能够在各个模块中也能获取到
    • 在 DefinePlugin 中设置值时加上 JSON.stringify 是因为如果不加,编译之后会是一个变量, 是为了保证编译之后值是一个字符串 编译之后可以在模块中拿到对应的值,相当于一个常量
"script": {
    "build": "webpack --env=production",
    "start": "webpack serve --env=development"
}
// webpacck.config.js
module.exports = (env, argv) => {
    let isDevelopment =  env.development; //是否是开发环境
    let isProduction = env.production; //是否是生产环境
    return {
        entry,
        output,
        module,
        plugins: [
            new webpack.DefinePlugin({
                'process.env.NODE_ENV': JSON.stringify(isDevelopment? 'development':'production')
            })
        ]
    }
}
配置方式index.js 模块webpack.config.js
package.json 中配置 mode可以不可以
package.json 中配置 env不可以不可以
DefinePlugin可以不可以
cross-env不行,但是有办法可以
  • cross-env
    • npm i cross-env -D
    • 因为 Windows/mac/linux 设置环境变量的方式不一样
      1. window: set key=value
      2. mac: export key=value
      • 为了跨平台 使用 cross-env
    • cross-env 是跨平台设置环境变量的意思
    • 配置之后可以在 webpack 配置文件中使用
    • 可以通过 DefinePlugin 将值传递到模块中使用
"script": {
    "build": "cross-env NODE_ENV=production webpack",
    "start": "cross-env NODE_ENV=producction webpack serve"
}
// webpack.config.js
console.log('process.env.NODE_ENV', process.env.NODE_ENV);
module.exports = {
    mode: process.env.NODE_ENV
    entry,
    output,
    module,
    plugins: [
        new webpack.DefinePlugin({
            'NODE_ENV': JSON.stringify(process.env.NODE_ENV)
        })
    ]
}
// index.js
// mode设置的,defineplugin传递的都可以使用 都是cross-env设定的值
console.log('mode设置的', process.env.NODE_ENV);
console.log('definePlugin设置的 NODE_ENV', NODE_ENV)

dotenv

  • 使用 dotenv,只需要将程序的环境变量配置写在.env 文件中
  • npm i dotenv-expand -D
// 使用
// 创建.env文件
MONGODB_HOST=localhost
MONGODB_PORT=27017
MONGODB_DB=test
MONGODB_URI=mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${MONGODB_DB}

// useEnv.js
const dotenvFile = '.env'
require('dotenv-expand')(
    require('dotenv').config({
        path: dotenvFile,
    })
);
console.log(process.env.MONGODB_HOST);
console.log(process.env.MONGODB_PORT);
console.log(process.env.MONGODB_DB);
console.log(process.env.MONGODB_URI);

开发环境配置

开发服务器

  • 安装: npm i webpack-dev-server -D
  • 在 webpack 配置文件的配置对象中: 配置 devServer 这是一个选项,用来配置开发服务器
  • 配置解析
    1. contentBase
      • contentBase: path.resolve(__dirname, "dist") 意思是将 dist 目录作为静态服务的根目录,但是实际上这句话没有什么意义,因为打包会生成一个 dist 目录,在访问开发服务器的时候,默认就可以访问 dist 目录下打包出来的文件(无论打包出来的文件夹配置成什么名字都可以访问到),也就是说 打包生成的文件夹不需要contentBase配置默认就是一个静态服务根目录
      • contentBase 选项的真实含义是配置额外的静态文件根目录,不用配置打包生成的文件目录(dist 目录)=> 如增加一个放置了图片等静态资源的文件夹 public,就需要配置才能访问
      • contentBase: path.resolve(__dirname, "public")
      • 所以 contentBase 是开启一个新的静态服务根目录的意思, dist 目录默认就能用
      • 如果配置之后 dist 下有 main.js 文件,public 下也有 main.js 文件,访问时会是 dist/main.js,会先读取打包的静态文件夹
    2. compress: true, 启用压缩 gzip
    3. port: 8080 端口号
    4. open: true, 启动之后自动打开浏览器
    5. devServer 中的 publicPath:表示打包生成的静态文件所在的位置(若是 devServer 里面的 publicPath 没有设置,则会认为是 output 里面设置的 publicPath 的值) output 中的 publicPath 表示的是打包生成的 index.html 文件里饮用资源的前缀
// webpack.config.js
module.exports = {
  mode,
  entry,
  output,
  module,
  plugins,
  devServer: {
    contentBase: path.resolve(__dirname, 'public'),
    compress: true, // 启用压缩
    port: 8080,
    open: true, // 启动之后自动打开浏览器
  },
};

支持 css

  • css-loader 用来翻译处理@import 和 url()
  • style-loader 把 css 插入 dom
  • 最后执行的 loader(也就是最左边的 loader)一定要返回一个 js 脚本
module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};
  • css-loader 和 style-loader 的区别

    css-loader 用来处理@import 引入一个 css 或者 url()里放背景图 style-loader css 转成 js 写一段脚本插入页面

  • css-loader 的importLoaders参数
{
    test: /\.css$/,
    use: [
        "style-loader",
        {
            loader: "css-loader",
            options: { // 如果不配置,默认值是0
                importLoaders: 1, //对于包含的css文件而言,要使用前面的几个loader来处理
                // 这里没有less-loader那样强大的处理,对于@import需要重头开始走postcss-loader处理,所以这里importLoaders设置为1
            }
        },
        "postcss-loader"
    ]
}
{
    test: /\.less$/,
    use: [
        "style-loader",
        {
            loader: "css-loader",
            options: {
                importLoaders: 0, // 引入的文件不需要再重头走less-loader,postcss-loader,因为less-loader很强大,直接会处理@import,到css-loader的时候已经没有@import了,所以importLoaders不需要额外配置
            }
        },
        "postcss-loader",
        "less-loader"
    ]
}
  • less-loader 会将@import 处理成好, 不需要 css-loader 对@import 做处理了

支持 less 和 sass

  • 安装 npm i less less-loader node-sass sass-loader -D
  • less 里面可以@import css 或者 less 文件,但是不能和 sass 混用
module.exports = {
  module: {
    rules: [
      {
        test: /\.less$/,
        use: ['style-loader', 'css-loader', 'less-loader'],
      },
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader'],
      },
    ],
  },
};

支持图片

  • npm i file-loader url-loader html-loader -D
  • file-loader 解决 css 等文件中的引入图片路径问题
  • url-loader 当图片小于 limit 的时候会把图片 base64 编码,大于 limit 参数的时候还是使用 file-loader 进行拷贝
  • 使用图片的几种方式
    1. let imageSrc = require('./images/1.jpg'); let img = new Image(); img.src=imageSrc; document.body.appendChild(img);
    2. background-image: url('./images/1.jpg')
    3. <img src='./images/1.jpg'/> 处理不了 需要 html-loader 来处 理 在 html 里直接通过相对路径的方式引入(这种方式严重不推荐),可以直接引入 cdn 地址或者引入设置为静态文件目录的 public 下面的文件
module.exports = {
  module: {
    rules: [
      {
        test: /\.(jpg|png|bmp|gif|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              name: '[hash:10].[ext]',
              esModule: false, //是否包装成一个es6模块(包装需要这样取值: module.default)
              limit: 8 * 1024, //8K
            },
          },
        ],
      },
      {
        test: /\.html$/,
        use: ['html-loader'], //这样就可以在html中使用图片资源文件
      },
    ],
  },
};

js 兼容性处理

  • Babel 其实是一个编译 javascript 的平台,可以把 es6/es7/react 的 jsx 转义成 es5
  • 安装依赖 npm i babel-loader @babel/core @babel/preset-env @babel/preset-react -D npm i @babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties -D
  • babel-loader 使用 babel 和 webpack 转译 javascript 文件,调用的是@babel/core 这个核心包里的文件
  • @babel/core babel 编译的核心包
  • @babel-preset-env

    这三者的关系 webpack loader 的本质就是一个函数,接受原来的内容返回新的内容,所以 babel-loader 就是一个函数,他的功能就是得到源代码,输出处理后的新代码,就这些function babelLoader(source){ return targetSource} 从源代码到转换后的代码(高级语法=>es5)的转换靠的就是@babel/core let targetSource = babelCore.transform(source) 是一个转换代码的引擎,只是个引擎,实际上并不知道要怎么转,默认情况下(在不做任何配置的情况下)你给他输入什么就会输出什么 真正的转换依靠 @babel/preset-env 是具体的转换规则 (es6 => es5 的规则)

function babelLoader(source) {
  let targetSource = babelCore.transform(source, {
    presets: ['@babel/preset-env'],
  });
}
  • @babel/preset-react babel 预设 将 react => es5
  • @babel/plugin-proposal-decorators 将类和对象装饰器编译成 es5
  • @babel/plugin-proposal-class-properties 将类的属性编译成 es5
  • 1. @babel/plugin-proposal-decorators 和 @babel/plugin-proposal-class-properties 要一起使用; 2. 并且顺序要 decorators 在前 class-properties 在后 3.legacy 配置为 true 的时候 loose 也要为 true
  • 插件是从后往前执行,预设是从前往后执行
// 预设:插件包。es6的语法规则有很多,对应就有很多的插件,把这些插件打成一个包,就被称为一个预设
// 插件:是一个一个的转换规则
module.exports = {
    module: {
        rules: [
            {
               test: /\.jsx?$/,
               use: {
                   loader: 'babel-loader',
                   options: {
                       presets: ["@bable/preset-env"],
                       plugins: [
                           ["@babel/plugin-proposal-decorators", {legacy: true}],
                           ["@babel/plugin-proposal-class-properties", {loose:true}]
                       ]
                   }
               }
            }
        ]
    }
}

// ["@babel/plugin-proposal-decorators", {legacy: true}]

// jsconfig.json
{
    "compilerOptions": {
        "experimentalDecorators": true
    }
}

// target 装饰的目标 key 属性名 属性描述器 decorator
function readonly(target, key, descriptor) {
    descriptor.writable=false;
}
class Person{
    @readonly PI=3.14
}
let p = new Person();
p.PI = 3.15
console.log(p); //PI不会被更改

// legacy: true  是转译装饰器老的写法  ==> 放在类的左边
// @classDecorator
// class P{}
// 新的写法是 ==> 放在类和名的中间: class @classDecorator P{}


// ["@babel/plugin-proposal-class-properties", {loose:true}]
class Person{ name="haha" }
let p = new Person();

// loose = true 会转义成 p.name = "haha"
// loose = false 会转义成 Object.defineProperty(p, 'name', { value: "haha" })

Eslint 代码校验

  • 安装: npm i eslint eslint-loader babel-eslint -D
  • 要想 eslint-loader 工作,要添加配置文件.eslintrc.js
  • vscode 安装 eslint 在根目录创建.vscode/settings.json
// .vscode/settings.json
{
    "eslint.validate": [
        "javascript",
        "javascriptreact",
        "typescript",
        "typescriptreact"
    ],
    "editor.codeActionsOnSave":{
        "source.fixAll.eslint": true
    }
}
module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'eslint-loader',
        options: { fix: true },
        exclude: /node_modules/,
        enforce: 'pre',
      },
    ],
  },
};

// .eslintrc.js
module.exports = {
  // root: true, //表示这是一个根配置文件,表示从零开始自己编写,还可以继承别人写好的,使用extends
  extends: 'airbnb',
  parser: 'babel-eslint', // 因为想进行代码检查,先要将代码转成抽象语法树,就是靠babel-eslint来转(解析器)
  parserOptions: {
    sourceType: 'module', //源代码类型
    ecmaVersion: 2015,
  },
  env: {
    browser: true, //代码会在浏览器运行
    node: true,
  },
  rules: {
    indent: 'off', //关闭锁进检查
    quotes: 'off', //关闭引号类型检查
    'no-console': 'error',
  },
};

sourceMap

  • sourceMap 是为了解决开发代码和实际运行代码不一致时帮助我们 debug 到原始开发代码的技术,把源代码和编译文件对应起来的技术

  • 看似配置想很多,其实就是五个关键字的任意组合 eval、source-map、cheap、module、inline

  • 关键字可以任意组合,但是有顺序要求

    eval 使用 eval 包裹模块代码 source-map 产生.map 文件 cheap 不包含列信息,也不包含 loader 的 sourcemap module 包含 loader 的 sourcemap(比如 jsx to js,babel 的 sourcemap)否则无法定义源文件 inline 将.map 作为 DataURI 嵌入,不单独产生.map 文件

  • 打包后的代码都是一行,源代码所有行的错误都对应打包后代码的第一行,没有意义。所以cheap只用于开发环境,因为开发环境不进行压缩,不会把所有代码变成一行

  • webpack 要将 react 源文件打包成 es5 代码,由于 webpack 不识别 jsx 代码,无法直接转换,所以先通过 babel-loader 将 jsx 转成 es5 的 js 代码,再通过 webpack 将其转换成打包后的代码 react 源文件 => es5 js 代码 => webpack 打包后代码 反过来生成 sourcemap 只能对应到 es5 js 代码,无法对应到真实的源代码,调试肯定是希望看到源代码而不是 通过 babel 转译之后的 es5 代码 可以在 react 通过 babel 编译的时候也生成一个 sourcemap 给 webpack,webpack 结合 sourcemap 和 es5 js 代码计算出最终的打包代码又生成一个 sourcemap,这样最终的 suorcemap 就包含之前的 sourcemap,就可以对应到源代码了,这就是module关键字的作用

  • 组合规则

    [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map

    inline-source-map 包含完整的行和列 以 base64 格式内联在打包后的文件里

  • source-map 最完整也是最慢的 包含完整的行和列

    1. 生成单独的 source-map 文件
    2. 包含完整的行和列信息
    3. 在目标文件里建立关系,从而能提示源文件原始位置
  • inline-source-map

    1. 以 base64 格式内联在打包后的文件里
    2. 包含完整的行和列的信息
    3. 在目标文件里建立关系,从而能提示源文件原始位置
    4. 不会生成单独的文件
    5. 包含 module
  • hidden-source-map

    1. 会在外部生成 source-map,但是在目标文件并没有建立关联,也不能提示原始错误位置
    2. 上线的代码中不能有 source-map,有可能会泄漏代码
    3. 但是在线上出 bug 的时候,需要调试,需要源代码
  • eval-source-map

    1. 会为每一个模块生成一个单独的 sourcemap 进行关联,并使用 eval 执行
    2. 包含 module
    3. eval 的内联是不一样的,eval 每个模块单独生成 map 单独缓存;inline 还是放在一起,只是会变成 base64 字符串内联到打包后的文件
  • nosources-source-map

    1. 会在外部生成 sourcemap 文件,也能找到源代码的位置,但是源代码的位置是空的
    2. 没有源代码,不能调试
    3. 一方面可以帮助定位错误的位置,另一方面又不会泄漏代码
  • cheap-source-map

    1. 轻量级的便宜的
    2. 只包含行映射,不包含列映射
    3. 不包含 babel 的 map 映射,也就是只能定位到通过 babel 转换成的 es5 js 代码,不能对应上源代码
    4. 不能看到最原始的代码
    5. 只有 cheap 不包含 module,其他的都包含
  • cheap-module-source-map

    1. 为了在轻量级的基础上对应到源代码,加上 module
    2. 如果是 source-map 前面不用加 module,因为他本身就已经是最全的了
    3. 只包含行不包含列
    4. 包含 babel 的 map 映射,可定位到最原始的代码
  • 最佳实践

  • 开发环境

    开发环境对 sourcemap 的要求是:速度快 调试更友好 要想速度快 推荐 eval-cheap-source-map (eval 重新编译的时候效率更高) 想调试友好 cheap-module-source-map 折中的选择是 eval-source-map

  • 生产环境

    首先排除内联; 因为一方面要隐藏源代码,另一方面要减少文件体积 想调试友好 source-map > cheap-source-map/cheap-module-source-map > hidden-source-map > nosources-source-map 想速度快,优先选择 cheap 折中的选择是 hidden-source-map

  • 为什么 eval 包裹?为什么更快?

    "source-map" 不能缓存模块的 sourcemap,每次都要生成完整的 sourcemap,把所有的 map 存放成一个文件,例如 100 个模块,只要改一个,缓存就会失效,整个 map 都需要重建 "eval-source-map"和 source-map 的内容一样多,但是可以缓存每一个模块的 sourcemap,在重新构建的时候速度更快

  • 测试环境

    source-map-dev-tool-plugin 实现了对 source map 生成进行更细粒度的控制 filename(string): 定义生成的 source map 的名称(如果没有值将会变成 inlined) append(string): 在原始资源后追加给定值。通常是#sourceMappingURL 注释。[url]被替换成 source map 文件的 URL

// 1. 控制台settings-preferences-sources中启用: enable javascript source maps
// 2. 让那个部署后的脚本指向自己的source map服务器
// webpack.config.js
const FileManagerPlugin = require('filemanager-webpack-plugin');
export default {
  devtool: false, // 把devtool关闭掉
  entry,
  output,
  module,
  plugins: [
    // 不再让webpack帮我们生成sourcemap
    new webpack.SourceMapDevToolPlugin({
      // 会在打包后文件的尾部添加一行这样的代码
      append: `\n//# sourceMappingURL=http://127.0.0.1:8081/[url]`,
      filename: '[file].map', // 如main.js.map
    }),
    // 文件管理插件,可以帮我们拷贝代码 删除代码
    // 先将生成的map文件拷贝到指定的目录下,然后删除dist下的map文件
    // 只有本人可以调试,因为map文件存放在本机,别人拿不到,部署只会部署dist下的文件
    new FileManagerPlugin({
      events: {
        onEnd: {
          copy: [
            {
              source: './dist/*.map',
              destination: 'C:/aproject/zhufengwebpack202103/maps',
            },
          ],
          delete: ['./dist/*.map'],
        },
      },
    }),
  ],
};

引入第三方模块

  • 方式一: 直接引入import _ from 'lodash' 把第三方库直接打包到了输出文件呢,特别的大
  • 方式二: 插件引入 --- ProvidePlugin。会自动向模块内部注入 lodash 模块,在模块内部可以通过*引用 new webpack.ProvidePlugin({'*':'lodash'})

    优点: 不需要在每个模块内部导入,可以直接使用 缺点: 也会把 lodash 打包到输出文件当中 没有全局的*变量,比如在 html 文件中就无法通过 window.*来引入 没有全局的函数变量,所以导入依赖全局变量的插件依旧会失效

  • 方式三: expose-loader

    expose-loader 可以把模块添加到全局对象上,在调试的时候比较有用 不需要任何其他插件配合,只要将下面的代码添加到所有的 loader 之前 还是会打包到输出文件中 还是需要在模块内至少手动引用一次,会把变量挂在全局变量 window 上

// 需要在文件模块中 require("lodash")
export default {
  entry,
  output,
  plugins,
  mudule: {
    rules: [
      {
        test: require.resolve('lodash'), // 返回的是模块的入口文件的绝对路径
        loader: 'expose-loader', // 暴露的loader,可以向window上挂载变量
        options: {
          exposes: {
            globalName: '_',
            override: true,
          },
        },
      },
    ],
  },
};
  • 方式四: 手动在 html 中引入 cdn 脚本,并且配合 externals,不需要引入
// html
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.js"></script>
<script> console.log(window.jQuery) </script>
// webpack.config.js
export default {
    entry,
    output,
    module,
    plugins,
    externals: {
        lodash: "_", // 如果在模块内部引用了lodash这个模块,会从window._上取值
        jquery: "jQuery", // 如果在模块内部引用了jquery这个模块,会从window.jQuery上取值
    }
}
  • 方式五: 借助 html-webpack-externals-plugin
// webpack.config.plugin
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');
export default {
  entry,
  output,
  module,
  plugins: [
    // 这个插件不能用了,因为html-webpack-plugin版本冲突(webpack5 对应html-webpack-plugin 5版本,但是html-webpack-externals-plugin依赖html-webpack-plugin2版本)
    // 等讲插件的时候可以基于最新的版本写一个HtmlWebpackExternalsPlugin
    new HtmlWebpackExternalsPlugin({
      externals: [
        //定义外部全局变量
        {
          module: 'lodash', // 模块名
          entry: 'https://cdn.bootcss.com/jquery/3.4.1/jquery.js', // cdn地址
          global: '_', // 全局变量名
        },
        {
          module: 'jquery',
          entry: 'https://cdn.bootcss.com/jquery/3.4.1/jquery.js', // cdn地址
          global: '$',
        },
      ],
    }),
  ],
};

watch 参数

  • webpack 定时获取文件的更新时间,并跟上次保存的事件进行对比,不一致就表示发生了变化。poll 就是用来配置每秒问多少次
  • 当检测文件不再发生变化,会先缓存起来,等待一段时间之后再通知监听者,这个等待时间通过 aggregateTimeout 配置
  • webpack 只会监听 entry 依赖的文件
  • 我们需要尽可能减少需要监听的文件数量和检查频率,当然频率的降低会导致灵敏度下降
// webapack.config.js
export default {
  entry,
  output,
  module,
  plugins,
  watch: true, // 开启监控模式
  watchOptions: {
    ignored: /node_modules/, //忽略变化的文件夹
    aggregateTimeout: 300, // 监听到变化后会等300毫秒再去执行(其实是一个防抖的优化)
    poll: 1000, // 每秒问操作系统多少次文件是否已经变更
  },
};

添加商标 webpack.BannerPlugin

  • new webpack.BannerPlugin('珠峰架构')

拷贝静态文件

  • copy-webpack-plugin可以拷贝源文件到目标目录
// webpack.config.js
const CopyWebpackPlugin = require('copy-webpack-plugin');
const path = require('path');
export default {
  entry,
  output,
  module,
  plugins: [
    new CopyWebpackPlugin({
      patterns: [
        {
          from: path.resolve(__dirname, 'src/static'), // 静态资源目录源地址
          to: path.resolve(__dirname, 'dist/static'), // 目标地址,相对于output的path目录
        },
      ],
    }),
  ],
};

打包前先清空输出目录 -- clean-webpack-plugin

// webpack.config.js
const { CleanWebapckPlugin } = require('clean-webpack-plugin');
export default {
  entry,
  output,
  module,
  plugins: [
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ['**/*'],
    }),
  ],
};

服务器代理

  • 如果你有单独的后端开发服务器 API,并且希望在同域名下发送 API 请求,那么代理某些 URL 会很有用
  • 不修改路径

    请求到 /api/users 现在会被代理到请求 http://localhost:3000/api/user devServer: { proxy: { "/api": "http://localhost:3000" }}

  • 修改路径
export default {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        pathRewrite: { '^/api': '' },
      },
    },
  },
};
  • before / after

    before 在 webpack-dev-server 静态资源中间件处理之前,可以用于拦截部分请求返回特定内容,或者实现简单的数据 mock before 是在静态资源中间件之前,一般用来配置 mock 数据或者配置一些中间件 after 是在静态资源中间件之后,一般用来进行一些异常处理,记录一些日志 基本没人用

export default {
  devServer: {
    before(app) {
      // webpack-dev-server本质上是一个express服务器
      app.get('/api/users1', function (req, res) {
        res.json([{ id: 1, name: 'zhuzhubefore' }]);
      });
    },
    // 中间就是静态文件 即产出的文件
    after(app) {
      app.get('/api/users2', function (req, res) {
        res.json([{ id: 1, name: 'zhuzhuafter' }]);
      });
    },
  },
};

webpack-dev-middleware

  • 就是在 express 中提供 webpack-dev-server 静态服务能力的一个中间件
  • webpack-dev-server 的好处是相对简单,直接安装依赖后执行命令即可
  • 使用 webpack-dev-middleware 的好处是可以在既有的 express 代码基础上快速添加 webpack-dev-middleware 的功能,同时利用 express 来根据需要添加更多的功能,如 mock 服务/代理 api 请求等
const express = require('express');
const app = express();
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackOptions = require('./webpack.config');
webpackOptions.mode = 'development';
const compiler = webpack(webpackOptions);
app.use(webpackDevMiddleware(compiler, {}));
app.listen(3000);

生产环境配置

提取 css

  • 因为 css 的下载和 js 可以并行,当一个 html 文件很大的时候,可以把 css 单独提取出来加载
  • 安装: mini-css-extract-plugin 只负责提取 css
// webpack.config.js
const MiniCssExtractPlugin = require('minii-css-extract-plugin');
export default {
  entry,
  output,
  module: {
    rules: [
      { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] },
      {
        test: /\.less$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'],
      },
      {
        test: /\.scss$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css', // main.css
    }),
  ],
};

指定图片和 css 目录

// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
export default {
  entry,
  output,
  module: {
    rules: [
      {
        test: /\.(jpg|png|bmp|gif|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              esModule: false,
              name: '[hash:10].[ext]',
              limit: 8 * 1024,
              outputPath: 'images', //默认是在打包目录下,配置之后指定写入到输出目录images里
              publicPath: '/images', // 配置了outputPath,就要加上publicPath,否则文件引用路径会出问题
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      // filename: '[name].css', // main.css
      filename: 'css/[name].css', //指定css目录
    }),
  ],
};

hash / chunkhash / contenthash

  • 三种 hash 从左往右稳定性越来越强,缓存性越来越强,性能越来越差
  • 文件指纹是指打包后输出的文件名和后缀
  • hash 一般是结合 cdn 缓存来使用。通过 webpack 构建之后,生成对应文件名自动带上对应的 md5 值。如果文件内容改变的话,那么对应文件哈希值也会改变,对应的 html 引用的 url 地址也会改变,触发 cdn 服务器从源服务器上拉取对应数据,进而更新本地缓存
占位符名称含义
ext资源后缀名
name文件名称
path文件的相对路径
folder文件所在文件夹
hash每次 webpack 构建时生成一个唯一的 hash 值
chunkhash根据 chunk 生成 hash 值,来源于同一个 chunk,则 hash 值就一样
contenthash根据内容生成 hash 值,文件内容相同 hash 值就相同
  • hash

    如果使用 hash,那么它是工程级别的,修改任何一个文件,所有的文件名都会发生改变

  • chunkhash

    代码块 hash 一个入口和它所以来的模块组成一个代码块 chunk。会根据不同的入口文件,进行依赖文件解析,构建对应的 hash 值 根据不同入口文件进行依赖文件解析,构建对应的哈希值。我们在生产环境里把一些公共库和程序入口文件区分开,单独打包构建,接着采用 chunkhash 生成哈希值,那么只要我们不改动公共库代码就可以保证其哈希值不会受影响

  • contenthash

    内容 hash 对应打包后的文件 使用 chunkhash 存在一个问题,就是当一个 js 文件中引入 css 文件,编译后他们的 hash 是相同的,而且只要 js 文件发生变化,关联的 css 文件 hash 也会改变。这个时候可以使用 mini-css-extract-plugin 配合 contenthash,保证即使 css 文件所处的模块里就算其他文件内容改变,只要 css 文件内容不变,那么就不会重复构建

css 兼容性

  • 为了浏览器的兼容性,有时必须加入-webkit,-ms,-o,-moz 这些前缀
  • 伪元素::placeholder 可以选择一个表单元素的占位文本,允许开发者和设计师自定义占位文本的样式 有兼容性问题
  • 安装

    postcss-loader 可以使用 postcss 处理 css postcss-preset-env 把现代的 css 转换成大多数浏览器能理解的 postcss preset env 已经包含了 autoprefixer 和 browsers 选项 npm i postcss-loader postcss-preset-env -D

// package.json
"browserslist": {
    "development": [
        "last 1 chrome version", // 最新的chrome版本
        "last 1 firefox version",
        "last 1 safari version",
    ],
    "production": [
        ">0.2%"
    ]
}
// 也可配置postcss.config.js
let postcssPresetEnv = require("postcss-preset-env");
module.exports = {
    plugins: [
        postcssPresetEnv({
            browsers: 'last 5 version'
        })
    ]
}
// webpack.config.js
module.exports = {
    mode,
    entry,
    output,
    plugins,
    module: {
        rules: [
            {test: /\.css$/, use: ['style-loader', 'css-loader', 'postcss-loader']}
        ]
    }
}

压缩 js/css/html

  • optimize-css-assets-webpack-plugin是一个优化和压缩 css 资源的插件
  • terser-webpack-plugin是一个优化和压缩 js 资源的插件, 已内置
  • 如果 mode=production,css/js/html 默认会自动压缩,不需要配置,mode=none 或者 development 就需要自己配置了
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const TerserPlugin = require('terser-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  mode: 'none',
  optimization: {
    // 压缩js
    minimize: true, //启用最小化
    minimizer: [
      new TerserPlugin(), // 以前是uglifyjs,不支持es6   用于压缩js
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html',
      minify: {
        // 压缩html
        collapseWhitespace: true, //删除空格
        removeComments: true, // 删除注释
      },
    }),
    new OptimizeCssAssetsWebpackPlugin(), // 压缩css
  ],
};

压缩图片 -- 一般不用

  • image-webpack-loader可以帮助对图片进行压缩和优化
  • npm i image-webpack-loader --dave-dev
  • 一般不会用到,图片一般不用 webpack 进行压缩
{
    test: /\.(png|svg|jpg|gif|jpeg|ico)$/,
    use: [
        "url-loader",
        {
            loader: "image-webpack-loader",
            options: {
                mozjpeg: {
                    progressive: true,
                    quality: 65
                },
                optipng: {
                    enabled: false
                },
                pngquant: {
                    quality: "65-90",
                    speed: 4
                },
                gifsicle: {
                    interlaced: false
                },
                webp: {
                    quality: 75
                }
            }
        }
    ]
}

px 自动转成 rem

  • lib-flexible + rem 实现移动端自适应
  • px2rem-loader 自动将 px 转换成 rem
  • 页面渲染式计算根元素的 font-size 值
  • npm i px2rem-loader lib-flexible -D
// webpack.config.jd
{
    test: /\.css$/,
    use: [
        "style-loader",
        "css-loader",
        "postcss-loader",
        {
            loader: "px2rem-loader",
            options: {
                remUnit: 75, // 一个rem是多少像素
                remPrecision: 8, //计算rem的单位,保留几位小数 设置精度
            }
        }
    ]
}
<!-- index.html -->
<head>
  <script>
    let docElement = document.documentElement; //根元素
    function setRemUnit() {
      // 把根元素的字体大小设置为宽度的十分之一
      docElement.style.fontSize = docElement.clientWidth / 10 + 'px';
    }
    setRemUnit();
    window.addEventListener('resize', setRemUnit);
  </script>
</head>

polyfill

@babel/polyfill

  • babel默认只转换新的javascript语法(如箭头函数),而不转换新的 api,比如 Iterator/Generator/Set/Map/Proxy/Reflect/Symbol/Promise 等全局对象,以及一些在全局对象上的方法(比如 Object.assign)都不会转码
  • 比如:es6 在 Array 对象上新增了 Array.from 方法,Babel 就不会转码这个方法,如果想让这个方法运行,必须使用babel-polyfill来转换等
  • babel-polyfill是通过向全局对象和内置对象的prototype上添加方法来实现的,比如运行环境中不支持 Array.prototype.find 方法,引入 polyfill,我们就可以使用 es6 方法来编写了,但是缺点就是会造成全局空间污染
  • @babel/preset-env 为每一个环境的预设
  • @babel/preset-env 默认支持语法转化,需要开启useBuiltIns配置才能转化 API 和实例方法
  • useBuiltIns 可选值包括: usage/entry/false,默认为 false,表示不对 polyfill 处理,这个配置是引入 polyfill 的关键
  • 安装: npm i @babel/polyfill

useBuiltIns

  • 涉及三个概念

    最新的 es 语法:如箭头函数 最新的 es api: 如 Promise 最新的 es 实例方法:如 String.prototype.includes

  • useBuiltIns 如果不设置,默认为 false

    @babel/preset-env 只转换新的语法,不转换 API 和方法 如果手动设置 useBuiltIns:false 还想实现 api 和方法的兼容性处理,要自己引入: import '@babel/polyfill' useBuiltIns:false; 手动 import '@babel/polyfill'; 会无视兼容性配置(指的是 package.json 中的 browserslist),直接引入所有的 polyfill,不管需不需要,一股脑全部引入

  • useBuiltIns:"entry"

    在项目入口引入一次(多次引入会报错) useBuiltIns:"entry" 会根据配置的浏览器兼容,引入浏览器不兼容的 polyfill。需要在入口文件手动添加 import '@babel/polyfill',会自动根据 browserslist 替换成浏览器不兼容的所有 polyfill corejs 是腻子的实现,2 是实现的版本。老的版本是 2,新的版本是 3 corejs 默认是 2,配置 2 的话需要单独安装 corejs@3 (npm i core- js@3) corejs 是自动安装的,安装@babel/preset-env 之后就会安装 corejs2 版本corejs3 不会自动安装,需要手动安装 这里需要指定 core-js 版本,如果"corejs":3,则 import '@babel/polyfill'需要改成 import 'core-js/stable'; import 'regenerator-runtime/runtime' 不管项目中有没有用到,不兼容的都会被引入,因为是严格按照浏览器的要求引入的 polyfill,浏览器缺什么就会引入什么,跟项目中有没有用到没有关系

// 入口文件 index.js
import '@babel/polyfill'; // corejs2
import 'core-js/stable'; // corejs3
import 'regenerator-runtime/runtime'; // corejs3

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              [
                '@babel/preset-env',
                {
                  useBuiltIns: 'entry',
                  corejs: 3,
                },
              ],
              '@babel/preset-react',
            ],
            plugins: [
              ['@babel/plugin-proposal-decorators', { legacy: true }],
              ['@babel/plugin-proposal-class-properties', { loose: true }],
            ],
          },
        },
      },
    ],
  },
};
  • useBuiltIns:"usage"

    usage 会根据匹配的浏览器兼容,以及你代码中用到的 api 进行 polyfill 实现按需加载 当设置 usage 时,polyfills 会自动按需添加,不再需要手动引入 @babel/polyfill usage 的行为类似于babel-transform-runtime不会造成全局污染 因此也不会对蕾丝 Array.prototype.includes 进行 polyfill

  • usage 和 entry 有一个本质区别

    entry 是全局引入,只需要在入口文件里单独引入一次就可以,所有的地方都可以使用 usage 是局部引入, 100 个模块都是用到 Promise,就会被引入 100 次, 所以 usage 可 能会增加文件体积(只是多增加了引入代码而已,代码量不会成倍增加)

  • 以上的配置是自己控制不了的,是 preset-env 自己引入的,如果想实现选择加载自己想要加载的一些方法进行 polyfill,可以使用babel-runtime

babel-runtime

  • babel 为了解决全局空间污染的问题,提供了单独的包babel-runtime用以提供编译模块的工具函数
  • 简单的说 babel-runtime 更像是一种按需加载的实现,比如哪里需要使用 Promise 只要在这个文件头部 import Promise from 'babel-runtime/core-js/promise'
  • 即哪里需要自己引入

babel-plugin-transform-runtime

  • 用这个插件就不再需要配置 useBuiltIns 了
  • @babel/plugin-transform-runtime 插件是为了解决

    多个文件重复引入相同 helpers(帮助函数) => 提取运行时 新 api 方法全局污染 => 局部引入

  • 启用插件 babel-plugin-transform-runtime 后,babel 就会使用 babel-runtime 下的工具函数
  • babel-plugin-transform-runtime 插件能够将这些工具函数的代码转换成 require 语句,指向为对 babel-runtime 的引用
  • babel-plugin-transform-runtime 就是可以在我们使用 api 时自动 import babel-runtime 里的 polyfill

    当我们使用 async/await 时,自动引入 babel-runtime/regenerator 当我们使用 es6 的静态事件或者内置对象时,自动引入 babel-runtime/core-js 移除内联 babel helpers 并使用 babel-runtime/helpers 来替换

  • corejs 默认是 3,配置 2 的话需要单独安装 @babel/runtime-corejs2
module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env', '@babel/preset-react'],
            plugins: [
              [
                '@babel/plugin-transform-runtime',
                {
                  corejs: 3, // 当我们使用es6的静态事件或者内置对象时,自动引入babel-runtime/core-js
                  helpers: true, // 移除内联babel helpers并使用babel-runtime/helpers来替换
                  regenerator: true, // 是否开启generator函数转换成使用regenerator runtime来避免污染全局
                },
              ],
              ['@babel/plugin-proposal-decorators', { legacy: true }],
              ['@babel/plugin-proposal-class-properties', { loose: true }],
            ],
          },
        },
      },
    ],
  },
};

如何选择最适合的配置

  • babel-runtime 适合在组件和类库中使用,局部引入,不污染全局
  • babel-polyfill 适合在业务项目中使用,不怕污染全局,所以可以使用
  • 局部引入 优点是不污染全局,缺点是增加文件体积
  • 全局引入 优点是降低文件体积,缺点是污染全局

polyfill-service

  • polyfill.io 自动化的 javascript polyfill 服务
  • polyfill.io 通过分析请求头信息中的 UserAgent 实现自动加载浏览器所需的 polyfills <script src="https://polyfill.io/v3/polyfill.min.js"></script>

小结

  • babel-polyfill
  • preset-env
  • useBuiltIns false import babel/polyfill entry 只入口引入一次 corejs2 import babel/polyfill corejs3 import corejs generator usage 按需引入 不需要自己引入,会根据使用了哪些功能自动引入
  • babel-runtime 需要手动引入
  • babel-plugin-transform-runtime 自动分析使用了哪些 局部引入
  • preset-env usebuiltins usage 与 preset-env + babel-plugin-transform-runtime 效果基本上是一样的 按需引入 局部引入


webpack 原理篇

webpack 介绍

  • webpack 是一个前端资源加载和打包工具,他根据模块的依赖关系进行静态分析,然后将这些模块按照制定的规则生成对应的静态资源

预备知识

toStringTag

  • Symbol.toStringTag 是一个内置的 symbol,它通常作为对象的属性键使用,对应的属性值应该是字符串类型,这个字符串用来表示该对象的自定义类型标签
  • 通常只有内置的 Object.prototype.toString()方法会去读取这个标签并把它包含在自己的返回值中
Object.prototype.toString.call('foo'); // "[object String]"
Object.prototype.toString.call([1, 2, 3]); // "[object Array]"
Object.prototype.toString.call(1); // "[object Number]"
Object.prototype.toString.call(true); // "[object Boolean]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
Object.prototype.toString.call(null); // "[object Null]"

let myExports = {};
Object.defineProperty(myExports, Symbol.toStringTag, { value: 'Module' });
console.log(Object.prototype.toString.call(myExports)); // "[object Module]"

defineProperty

  • defineProperty 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象

Object.create(null)

  • 使用 create 创建的对象,没有任何属性,把它当作一个非常纯净的 map 来使用,可以定义 hasOwnProperty、toString 方法,完全不必担心会将原型链上的同名方法覆盖掉
  • 在我们使用 for..in 循环的时候会遍历对象原型链上的属性,使用 create(null)就不必再对属性进行检查了
var ns = Object.create(null);
if (typeof Object.create !== 'function') {
  Object.create = function (proto) {
    function F() {}
    F.prototype = proto;
    return new F();
  };
}
console.log(ns);
console.log(Object.getPrototypeOf(ns));

同步加载

  • 打包后的文件 首先是一个自执行函数
// index.js
let title = require('./title');
console.log(title);

// title.js
module.exports = 'title';
// 对于我们自己的模块 模块id是相对于根目录的相对路径
var modules = {
  './src/title.js': function (module, exports, require) {
    // 函数里面是title.js文件里的内容
    module.exports = 'title';
  },
};
// 缓存对象
// 模块加载后会把加载到的结果放在缓存对象cache里
let cache = {};

// 因为commonjs浏览器是不识别的,不能识别require方法,所以需要自己实现一个浏览器能够识别的require方法
function require(moduleId) {
  // 如果缓存中有就直接使用
  if (cahce[moduleId] !== undefined) {
    return cache[moduleId].exports;
  }
  let module = (cache[moduleId] = { exports: {} });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}

// 执行入口文件的代码(即index.js中的代码)
let title = require('./src/title.js');
console.log(title);

同步加载的模块兼容性实现

  • 压缩只能压缩变量,不能压缩属性,所以只能在定义属性的时候短一点,金科鞥减少体积(require.r require.o require.d)
  • js 源代码首先会走 babel 转换,babel 可能会把 esm 转换成 commonjs,可能不转换;webpack 都会转成 commonjs
  • 在 webpack 中既支持 commonjs 也支持 esmodules,说明他们是可以互相转换的,所以可以

    commonjs 加载 commonjs common.js 加载 ES6 modules

    将 esmodule 编译成 commonjs: 涉及 require.r require.d 方法 给 exports 对象上挂属性 只要模块内出现了 import 或者 export 那就是 esmodule ES6 modules 加载 ES6 modules 实现和 commonjs 加载 esm 基本一致,只是加了将 index.js 入口文件表示为 esmodule var exports = {}; require.r(exports); ES6 modules 加载 common.js commonjs 打包之前之后基本没有任何区别,不需要转换

commonjs 加载 commonjs

// index.js
let title = require('./title');
console.log(title.name);
console.log(title.age);

// title.js
exports.name = 'title_name';
exports.age = 'title_age';

// 实现和上面的同步加载一致

common.js 加载 ES6 modules

// index.js
// 通过commonjs导入title模块
let title = require('./title');
console.log(title.default);
console.log(title.age);

// title.js
export default 'title_defualt';
export const age = 'title_age';
// 编译实现
// 将esmodule编译成commonjs: 涉及 require.r require.d方法,给exports对象上挂属性
var modules = {
  './src/title.js': function (module, exports, require) {
    require.r(exports); // 标示 这是一个es模块 webpack中所有属性方法 都是一个字母
    require.d(exports, {
      default: () => DEFAULT_EXPORTS,
      age: () => age,
    });
    const DEFAULT_EXPORTS = 'title_defalt';
    const age = 'title_age';
  },
};
var cache = {};
function require(moduleId) {
  var cacheModule = cache[moduleId];
  if (cacheModule !== undefined) {
    return cacheModule.exports;
  }
  var module = (cache[moduleId] = { exports: {} });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}
require.r = (exports) => {
  // 表示es6 modules
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); // toString时输出 [object Module]
  Object.defineProperty(exports, '__esModule', { value: true }); // exports.__esModule = true
};
require.d = (exports, definition) => {
  for (let key in definition) {
    Object.defineProperty(exports, key, { get: definition[key] });
  }
};

let title = require('./src/title.js');
console.log(title.default);
console.log(title.age);

ES6 modules 加载 ES6 modules

// index.js
import title, { age } from './title';
console.log(title);
console.log(age);

// title.js
export default 'title_defualt';
export const age = 'title_age';
// 编译实现
var modules = {
  './src/title.js': function (module, exports, require) {
    require.r(exports); // 标示 这是一个es模块 webpack中所有属性方法 都是一个字母
    require.d(exports, {
      default: () => DEFAULT_EXPORTS,
      age: () => age,
    });
    const DEFAULT_EXPORTS = 'title_defalt';
    const age = 'title_age';
  },
};
var cache = {};
function require(moduleId) {
  var cacheModule = cache[moduleId];
  if (cacheModule !== undefined) {
    return cacheModule.exports;
  }
  var module = (cache[moduleId] = { exports: {} });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}
require.r = (exports) => {
  // 表示es6 modules
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); // toString时输出 [object Module]
  Object.defineProperty(exports, '__esModule', { value: true }); // exports.__esModule = true
};
require.o = (obj, prop) => obj.hasOwnProperty(prop); // 可有可无,不重要
require.d = (exports, definition) => {
  for (let key in definition) {
    if (require.o(definition, key)) {
      Object.defineProperty(exports, key, { get: definition[key] });
    }
  }
};

var exports = {}; // +
require.r(exports); // + 将index.js标示为esmodule
let title = require('./src/title.js');
console.log(title.default);
console.log(title.age);

ES6 modules 加载 common.js

// index.js
import title, { age } from './title';
console.log(title); // {name: "title_name", age:"title_age"}
console.log(age); // 'title_age'
// title.js
// exports.name = 'title_name';
// exports.age = 'title_age'
module.exports = {
  name: 'title_name',
  age: 'title_age',
};
// 编译实现
var modules = {
  './src/title.js': function (module, exports, require) {
    module.exports = {
      name: 'title_name',
      age: 'title_age',
    };
  },
};
var cache = {};
function require(moduleId) {
  var cacheModule = cache[moduleId];
  if (cacheModule !== undefined) {
    return cacheModule.exports;
  }
  var module = (cache[moduleId] = { exports: {} });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}
require.r = (exports) => {
  // 表示es6 modules
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); // toString时输出 [object Module]
  Object.defineProperty(exports, '__esModule', { value: true }); // exports.__esModule = true
};
require.o = (obj, prop) => obj.hasOwnProperty(prop); // 可有可无,不重要
require.d = (exports, definition) => {
  for (let key in definition) {
    if (require.o(definition, key)) {
      Object.defineProperty(exports, key, { get: definition[key] });
    }
  }
};
// 返回获取default默认导出的getter方法
// esm取module.default,commonjs取module本身
require.n = function (module) {
  let getter = module.__esModule ? () => module.default : () => module;
  return getter;
};

var exports = {};
require.r(exports);
let title = require('./src/title.js');
let title_default = require.n(title); // +
console.log(title_default()); // +
console.log(title.age);

异步加载

  • 通过 import 加载的代码块的 chunkId 是如何计算的?
    1. 得到加载模块相对于根目录的相对路径 ./src/title.js
    2. 将./和/转成下划线 src_title_js, 这就是 chunkId
// index.js
document.addEventListener('click', () => {
  import('./title').then((result) => {
    console.log(result.default);
  });
});
// title.js
export default 'title';
// 简易实现

// 在原始的mian.js里没有任何模块定义(因为例子中只有一个title.js,还是异步加载,所以这里开始是空)
var modules = {};
// 缓存对象
var cache = {};
// 能在浏览器中跑的require方法
function require(moduleId) {
  var cacheModule = cache[moduleId];
  if (cacheModule !== undefined) {
    return cacheModule.exports;
  }
  var module = (cache[moduleId] = { exports: {} });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}

require.r = (exports) => {
  // 表示es6 modules
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); // toString时输出 [object Module]
  Object.defineProperty(exports, '__esModule', { value: true }); // exports.__esModule = true
};
// 返回获取default默认导出的getter方法
require.n = function (module) {
  let getter = module.__esModule ? () => module.default : () => module;
  return getter;
};
// 通过require.m属性可以获取模块定义(在HMR有用)
require.m = moudles;
// 判断对象上有没有某个属性
require.o = (obj, prop) => obj.hasOwnProperty(prop);
// 给对象上定义属性
require.d = (exports, definition) => {
  for (let key in definition) {
    if (require.o(definition, key)) {
      Object.defineProperty(exports, key, { get: definition[key] });
    }
  }
};

require.f = {};

// 在一个项目中可能会有很多的代码块,每个代码块都会有状态
// key是代码块的名字, 0表示此代码块已经加载完成
let installedChunks = {
  main: 0, // 当前的入口代码块,他肯定是加载完成的
};

// 通过jsonp加载chunk代码,并且创建promise放到数组里
require.f.j = (chunkId, promises) => {
  // 先判断installedChunks中有没有加载了或者正在加载的data,如果有直接复用
  let installedChunkData = require.o(installedChunks, chunkId)
    ? installedChunks[chunkId]
    : undefined;
  if (installedChunkData !== 0) {
    // !=0表示没有加载完成
    // 第二次以及以后懒加载
    if (installedChunkData) {
      // !=0但是有值,说明很可能正在加载中
      promises.push(installedChunkData[2]); // 直接取出上一个promise放到数组中
    } else {
      // 第一次懒加载时 installedChunkData=undefined
      let promise = new Promise((resolve, reject) => {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      installedChunkData[2] = promise; // installedChunkData = [resolve, reject, promise]
      promises.push(promise);
      // 开始加载
      let url = require.p + require.u(chunkId);
      require.l(url);
    }
  }
};
// 通过jsonp异步加载代码块 chunkId=src_title_js
require.e = (chunkId) => {
  let promises = [];
  Object.keys(require.f).forEach((func) => func(chunkId, promises));
  return Promise.all(promises);
};

// 文件名
require.u = (chunkId) => '' + chunkId + '.js';
// 路径前缀
// 就是webpack.config.js中 output里面配置的publicPath的值,没有配置就是空
require.p = '';

// 加载
require.l = (url, done, key, chunkId) => {
  // jsonp原理
  let script = document.createElement('script');
  script.src = url;
  document.head.appendChild(script);
};
function webpackJsonpCallback(data) {
  let [chunkIds, moreModules] = data;
  for (let moduleId in moreModules) {
    // 模块合并
    require.m[moduleId] = moreModules[moduleId];
  }
  for (let chunkId, i = 0; i < chunkIds.length; i++) {
    chunkId = chunkIds[i]; // src_title_js
    installedChunks[chunkId][0](); // 让这个resolve对应的promise变成成功态
    installedChunks[chunkId] = 0; // 加载完成
  }
}
var chunkLoadingGlobal = (window['webpack5'] = []);
chunkLoadingGlobal.push = webpackJsonpCallback;

var exports = {};
document.addEventListener('click', () => {
  require
    .e('src_title_js')
    .then(() => require('./src/title.js'))
    .then((result) => {
      console.log(result.default);
    });
});

// src_title_js.js文件
// "webpack5"名字随意
window['webpack5'].push([
  ['src_title_js'],
  {
    './src/title.js': (module, exports, require) => {
      require.r(exports);
      require.d(exports, {
        default: () => DEFAULT_EXPORTS,
      });
      const DEFAULT_EXPORTS = 'title';
    },
  },
]);

// 面试被问 webpack 编译后的代码风格是原来的哪种 js 的写法

抽象语法树

  • webpack 和 lint 等很多工具和库的核心都是通过 AST 抽象语法树这个概念来实现对代码的检查和分析等操作的
  • 通过了解抽象语法树这个概念,你也可以随手编写类似的工具

抽象语法树用途

  • 代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码错误提示、代码自动补全等等
    • 如 JSLint、JSHint 对代码错误或风格的检查,发现一些潜在的错误
    • IDE 的错误提示、格式化、高亮、自动补全等等
  • 代码混淆压缩 -UglifyJS2 等
  • 优化变更代码,改变代码结构使达到想要的结构
    • 代码打包工具 webpack、rollup 等等
    • CommonJS、AMD、CMD、UMD 等代码规范之间的转化
    • CoffeeScript、TypeScript、JSX 等转化为原生 Javascript

抽象语法树的定义

  • 这些工具的原理都是通过 javascript Parser 把代码转化成一棵抽象语法树(AST)。这棵树定义了代码的结构,通过操纵这棵树,可以精准的定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化、变更等操作。

javascript Parser

  • javascript Parser,把 js 源码转化为抽象语法树的解析器
  • 浏览器会把 js 源码通过解析器转为抽象语法树,再进一步转化为字节码或者直接生成机器码
  • 一般来说每个 js 引擎都会有自己的抽象语法树格式,chrome 的 v8 引擎,firefox 的 SpiderMonkey 引擎等等。
  • 常见的 javascript Parser
    • esprima
    • traceur
    • acorn
    • shift
  • esprima

    通过 esprima 把源码转化成 AST 通过 estraverse 遍历并更新 AST 通过 escodegen 将 AST 重新生成源码 astexplorer AST 的可视化工具 astexplorer.net/

// mkdir zhufengast
// cd zhufengast
// cnpm i esprima estraverse escodegen- S

let esprima = require('esprima');
var estraverse = require('estraverse');
var escodegen = require('escodegen');
let code = 'function ast(){}';
let ast = esprima.parse(code);
let indent = 0;
function pad() {
  return ' '.repeat(indent);
}
// estraverse会以深度优先的方式遍历语法树所有节点
// 每个节点会有进入和离开两个步骤
estraverse.traverse(ast, {
  enter(node) {
    console.log(pad() + node.type, '进入');
    if (node.type == 'FunctionDeclaration') {
      node.id.name = 'ast_rname';
    }
    indent += 2;
  },
  leave(node) {
    indent -= 2;
    console.log(pad() + node.type, '离开');
  },
});
// Program
//   FunctionDeclaration
//     Identifier
//     Identifier
//     BlockStatement
//     BlockStatement
//   FunctionDeclaration
// Program
let generated = escodegen.generate(ast);

console.log(generated);

Babel

  • babel 能够转译 ecmascript2015+ 的代码,使他在旧的浏览器或者环境中也能运行
  • 工作过程分为三部分

    Parse(解析) 将源代码转换成抽象语法树,树上有很多的 estree 节点 Transform(转换) 对抽象语法树进行转换 Generate(代码生成) 将上一个经过转换的抽象语法树生成新的代码

babel 插件

  • @babel/core babel 的编译器,核心 api 都在这里面,比如常见的 transform、parse

    核心库,提供语法树的生成/遍历功能

  • babylon babel 的解析器 -- 类似于 esprima
  • babel-types 用于 AST 节点的 lodash 式工具库,它包含了构造、验证以及变换 AST 节点的方法,对编写处理 AST 逻辑非常有用

    工具库,帮助生成相应节点

  • babel-traverse用于对 AST 的遍历,维护整棵树的状态,并且负责替换、移除、添加节点 -- 类似于 estraverse

转换箭头函数

  • @babel/core 提供 transform 方法,里面包含了所有的流程

    根据源代码生成老的语法树 遍历老的语法树 遍历的时候要找你注册的插件 找这些插件指定

  • 插件的核心就是将老的语法树转成新的语法树,比较新老语法树的差异,以最小的代价转换过来,尽可能复用
  • 所谓的 babel 插件就是一个对象, 里面有属性 visitor 对象
  • 当 babel 在遍历语法树的时候,会看有没有插件里的访问器,拦截节点,如果有的话就会把对应的节点路径传给此函数
  • babel-plugin-transform-es2015-arrow-functions
  • npm i @babel/core babel-types -D
// 转换前
const sum = (a, b) => a + b;
// 转换后
const sum = function sum(a, b) {
  return a + b;
};
// 实现
let babel = reuqire('@babel/core');
const code = `const sum = (a,b) => a+b`;
let ArrowFunctionPlugin = {
  visitor: {
    ArrowFunctionExpression(nodePath) {
      let node = nodePath.node;
      node.type = 'FunctionExpression';
    },
  },
};
const result = babel.transform(code, {
  plugins: [ArrowFunctionPlugin],
});
console.log(result.code);
// 转换前
const sum = (a, b) => {
  console.log(this);
  return a + b;
};
// 转换后
var _this = this;
const sum = function (a, b) {
  console.log(_this);
  return a + b;
};
let babel = reuqire('@babel/core');
let types = require('babel-types');
let ArrowFunctionPlugin2 = {
  visitor: {
    ArrowFunctionExpression(nodePath) {
      let node = nodePath.node; // 获取节点
      const thisBinding = hoistFunctionEnvironment(nodePath);
      node.type = 'FunctionExpression';
    },
  },
};
function hoistFunctionEnvironment(fnPath) {
  // Program
  // 从当前节点向上查找
  const thisEnvFn = fnPath.findParent((p) => {
    // 是一个函数 不能是箭头函数 或者 是根节点也可以
    return (p.isFunction() && !p.isArrowFunctionExpression()) || p.isProgram();
  });
  // 从当前节点向下查找
  // 找当前作用域哪些地方用到了this
  let thisPaths = getScopeInfoInfomation(fnPath);
  // 声明了一个this的别名变量 默认是_this 真实源代码中会判断 如果_this被占用了就用_this2
  let thisBinding = '_this';
  if (thisPaths.length > 0) {
    // 向thisEnvFn这个作用域内添加一个变量
    // 变量名为_this,初始化的值为this
    thisEnvFn.scope.push({
      // 箭头函数里面嵌套箭头函数不会添加重复代码
      id: types.identifier(thisBinding),
      init: types.thisExpression(),
    });
    thisPaths.forEach((thisPath) => {
      // 创建一个_this的标识符
      let thisBindingRef = types.identifier(thisBinding);
      // 把老得路径上的节点替换成新节点
      thisPath.replaceWith(thisBindingRef);
    });
  }
}
function getScopeInfoInfomation(fnPath) {
  let thisPaths = [];
  // 遍历当前path所有的子节点路径
  fnPath.traverse({
    ThisExpression(thisPath) {
      thisPaths.push(thisPath);
    },
  });
  return thisPaths;
}

const result = babel.transform(code, {
  plugins: [ArrowFunctionPlugin2],
});
console.log(result.code);

把类编译成 Function

  • @babel/plugin-transform-classes
// 编译前
class Person {
  constructor(name) {
    this.name = name;
  }
  getName() {
    return this.name;
  }
}
// 编译后
function Person(name) {
  this.name = name;
}
Person.prototype.getName = function () {
  return this.name;
};
let babel = require('@babel/core');
let t = require('babel-types');
let source = `
class Person {
    constructor(name) {
        this.name=name;
    }
    getName() {
        return this.name;
    }
}
`;

let transformClasses = (state, opts) => {
  return {
    visitor: {
      ClassDeclaration(nodePath) {
        let { node } = nodePath;
        let id = node.id; // {type: 'Identifier', name: 'Person'}
        let methods = node.body.body; // 拿到类上的方法
        let nodes = [];
        methods.forEach((classMethod) => {
          if (classMethod.kind === 'constructor') {
            // 是构造函数
            let constructorFunction = types.functionDeclaration(
              id,
              classMethod.params,
              classMethod.body,
              classMethod.generator,
              classMethod.async
            );
            nodes.push(constructorFunction);
          } else {
            let right = types.functionExpression(
              id,
              classMethod.params,
              classMethod.body,
              classMethod.generator,
              classMethod.async
            );
            let prototype = types.memberExpression(
              id,
              types.identifier('prototype')
            );
            let left = types.memberExpression(prototype, classMethod.key);
            let assignmentExpression = types.assignmentExpression(
              '=',
              left,
              right
            );
            nodes.push(assignmentExpression);
          }
        }); // 为什么不用构建ExpressionStatement?而是直接构建AssignmentExpression????????????
        if (nodes.length === 1) {
          nodePath.replaceWith(nodes);
        } else {
          nodePath.replaceWithMultiple(nodes);
        }
      },
    },
  };
};

const result = babel.transform(source, {
  plugins: [transformClasses],
});
console.log(result.code);

webpack TreeShaking 插件(是 babel 插件)

  • 实现按需加载

    import { flatten,concat } from "lodash" 转换成: import flatten from "lodash/flatten"; > import concat from "lodash/flatten";

  • webpack 配置按需加载: babel-plugin-import
// webpack.config.js
// 编译顺序为首先plugins从左往右,然后presets从右往左
module.exports = {
  mode,
  entry,
  output,
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            plugins: [['import', { library: 'lodash' }]],
            // 配置自定义babel插件
            // plugins: [
            //     [
            //         path.resolve(__dirname, 'plugins/babel-plugin-import.js'),
            //         {
            //             library: 'lodash'
            //         }
            //     ]
            // ]
          },
        },
      },
    ],
  },
};
  • 实现按需加载的 babel 插件
let babel = require('@babel/core');
let t = require('babel-types');
let visitor = {
  ImportDeclaration(nodePath, state) {
    let { opts } = state; //传入的参数
    let { node } = nodePath;
    let specifiers = node.specifiers; // flatten、concat
    let source = node.source; // lodash
    // 只有第一个specifiers不是默认导入的才会进来
    // 如果已经转换过了 已经把一个普通导入变成默认导入 那就不要进来了
    if (
      opts.library === source.value &&
      !t.isImportDefaultSpecifier(specifiers[0])
    ) {
      const importDeclaration = specifiers.map((specifier, index) => {
        return t.ImportDeclaration(
          [t.ImportDefaultSpecifier(specifier.local)],
          t.StringLiteral(`${source.value}/${specifier.imported.name}`)
        );
      });
      if (importDeclaration.length === 1) {
        nodePath.replaceWith(importDeclaration[0]);
      } else {
        nodePath.replaceWithMultiple(importDeclaration);
      }
    }
  },
};

module.exports = function () {
  return {
    visitor,
  };
};

实现自己的 webpack 了解 webpack 的工作流

webpack 编译流程

  1. 初始化参数:从配置文件和 shell 语句中读取并合并参数,得出最终的配置对象
  2. 用上一步得到的参数初始化 Compiler 对象
  3. 加载所有配置的插件
  4. 执行对象的 run 方法开始执行编译
  5. 根据配置中的 entry 找出入口文件
  6. 从入口文件出发,调用所有配置的 Loader 对模块进行编译
  7. 再找出该模块以来的模块,递归直到所有入口依赖的文件都经过处理
  8. 根据入口和模块之间的依赖关系,组装成 i 一个个包含多个模块的 chunk
  9. 再把每个 chunk 转换成一个单独的文件加入到输出列表
  10. 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
let webpack = require("./webpack"); // 自实现的webpack
const options = require("./webpack.config");

// 1. 初始化参数,从配置文件中读取配置对象,然后和shell参数进行合并得到最终的配置对象(在webpack函数中进行)
// 2. 用上一步得到的参数初始化compiler对象(在webpack函数中进行
// 3. 加载所有配置的插件: 依次调用插件的apply方法,传入compiler对象作为参数(在webpack函数中进行)
let compiler = webpack(options);
// 4. 调用Compiler对象的run方法开始执行编译工作
// 5. 在run方法中根据配置中的entry找到入口文件
compiler.run((err, stats) => {
    console.log(err);
    console.log(stats.toJson({
        entries: true, // 入口信息
        modules: true, // 本次打包有哪些模块
        chunks: true, // 代码块
        assets: true, // 产出的资源
        files: true, // 最后生成了哪些文件
    }))
})


// webpack.js
// webpack是一个函数,接收options作为参数,返回一个compiler对象
let Compiler = require("./Compiler");
function webpack(options) {
    // 1. 初始化参数(options和shell参数)

    // 获取shell参数
    let shellOptions = process.argv.slice(2).reduce((config, args) => {
        let [key, value] = args.split("="); // --mode=production
        config[key.slice(2)]=value;
        return config;
    }, {});
    // 合并optons参数和shell参数
    let finalOptions = {...options, ...shellOptions};

    let compiler = new Compiler(finalOptions);
    // 3. 循环加载所有配置的插件
    if(finalOptions.plugins && Array.isArray(finalOptions.plugins)) {
        for(let plugin of options.plugins) {
            plugin.apply(compiler);
        }
    }
    
    return compiler;
}
module.exports = webpack;

// runPlugin.js
class RunPlugin{
    apply(compiler) {
        // 注册run这个钩子
        compiler.hooks.run.tap('RunPlugin', () => {
            console.log("挂载runPlugin")
        })
    }
}
module.exports = RunPlugin;

// donePlugin.js
class DonePlugin{
    apply(compiler) {
        // 注册done这个钩子
        compiler.hooks.done.tap('DonePlugin', () => {
            console.log("挂载donePlugin")
        })
    }
}
module.exports = DonePlugin;

// selfLoader
// loader就是一个函数,接收原始内容,返回转换后的内容
function selfLoader(source) {
    console.log('logger1-loader');
    return source + '//logger1'
}
module.exports = selfLoader;



// Compiler.js
let {SyncHook} = require("tapable");
let fs = require("fs");
let path = require("path");
let types = require("babel-types"); // 判断某个节点是否是某种类型,生成某个新的节点
let parser = require("@babel/parser"); // 把源码生成ast语法树  @babel/parser就是babylon
let traverse = require("@babel/traverse").default; // 遍历器,用来遍历语法树
let generator = require("@babel/generator").default; // 生成器,根据语法树重新生成代码

let rootPath = toUnixPath(this.options.context || process.cwd());
class Compiler{
    constructor(options) {
        this.options = options;
        this.hooks = {
            run: new SyncHook(), // 开启编译
            emit: new SyncHook(), // 写入文件系统
            done: new SyncHook(), // 编译工作全部完成
        }
        // webpack4:数组 webpack5:Set
        this.entries = new Set(); // 所有的入口模块
        this.modules = new Set(); // 所有的模块
        this.chunks = new Set(); // 所有的代码块
        this.assets = {}; //存放本次编译要产出的文件
        this.files = new Set(); // 存放着本次编译所有的产出的文件名
    }
    run(callback) {
        // 触发注册了的钩子执行
        this.hooks.run.call();

        // 5. 根据配置中的entry找到入口文件(entry最终都是一个对象[字符串写法相当于语法糖])
        let entry = {};
        if(typeof this.options.entry === 'string') {
            entry.main = this.options.entry;
        }else{
            entry = this.options.entry;
        }
        // 开始真正的编译了
        // 6. 从入口文件出发,调用所有配置的loader对模块进行编译
        // webpack配置文件可能会配置: context: process.cwd()
        let rootPath = this.options.context || process.cwd();
        for(let entryName in entry) {
            let entryPath = toUnixPath(path.join(rootPath, entry[entryName])); // 入口文件的绝对路径
            // 开始编译入口文件, 返回一个入口模块
            let entryModule = this.buildModule(entryName, entryPath);
            this.entries.add(entryModule);
            // this.modules.add(entryModule); 可加可不加

            // 8. 根据入口和模块之间的依赖关系 组装成一个个包含多个模块的chunk
            let chunk = { name: entryName, entryModule, modules: Array.from(this.modules).filter(module => module.name === entryName)};
            this.chunks.add(chunk);
        }
        // 9. 再把每个chunk转换成一个单独的文件加入到处输出列表 
        // 输出列表就是this.assets对象 key是文件名 值是文件内容
        let output = this.options.output;
        this.chunks.forEach(chunk => {
            let filename = output.filename.replace('[name]', chunk.name);
            this.assets[filename] = getSource(chunk);
        });

        // 10. 生成文件 文件内容写入文件系统
        this.hooks.emit.call();
        
        this.files = Object.keys(this.assets); // 文件名数组
        for(let file in this.assets) {
            let filePath = path.join(output.path,file) 
            fs.writeFileSync(filePath, this.assets[file]);
        }
        // 到这里, 编译工作就全部结束了 可以触发done的回调了
        this.hooks.done.call();
        callback(null, {
            toJson: () => ({
                entries: this.entries,
                chunks: this.chunks,
                modules: this.modules,
                files: this.files,
                assets: this.assets
            })
        })
    }
    buildModule(entryName, modulePath) {
        // 读取出来此模块的内容
        let originalSourceCode = fs.readFileSync(modulePath, 'utf8');
        let targetSourceCode = originalSourceCode;
        // 调用所有配置的loader对模块进行编译
        let rules = this.options.module.rules;
        // 得到了本文件模块生效的loader有哪些
        let loaders = [];
        for(let i = 0; i < rules.length; i++) {
            // if(rules[i].test.test(modulePath)){}
            if(modulePath.match(rules[i].test)){
                loaders = [...loaders, ...rules[i].use]
            }
        }
        for(let i = loaders.length - 1; i>=0; i--) {
            targetSourceCode = require(loaders[i])(targetSourceCode);
        }
        // 7. 再找出该模块依赖的模块, 再递归本步骤直到所有入口依赖的文件都经过本步骤的处理
        let moduleId = './' + path.posix.relative(rootPath, modulePath); // 模块的相对路径就是模块id  ./src/index.js
        let module = { id: moduleId, dependencies: [], name: entryName}; // name是所属代码块的名字
        // 把转换后的源码转成抽象语法树
        let ast = parser.parse(targetSourceCode, {sourceType: "module"});
        traverse(ast, {
            CallExpression: (nodePath) => {
                let { node } = nodePath;
                if(node.callee.name === 'require') { // 说明是require('./xxx'),是依赖的模块
                    // 要引入模块的相对路径
                    let moduleName = node.arguments[0].value; './title'
                    // 为了获取要加载的模块的绝对路径depModulePath 第一步获取当前模块的所在目录
                    let dirname = path.posix.dirname(modulePath);
                    let depModulePath = path.posix.join(dirname, moduleName);
                    // 给依赖模块绝对路径添加后缀
                    let extensions  = this.options.resolve.extensions;
                    depModulePath = tryExtensions(depModulePath, extensions, moduleName, dirname);
                    // 依赖模块的模块id
                    let depModuleId = './' + path.posix.relative(rootPath, depModulePath); // ./src/title.js 
                    node.arguments = [types.stringLiteral(depModuleId)]; // ./title.js => ./src/title.js
                    // 判断现有的已经编译的modules里有没有这个模块,如果有不用添加依赖了,如果没有则需要添加
                    let alreadyModuleIds = Array.from(this.modules).map(module => module.id);
                    if(!alreadyModuleIds.includes(depModuleId)) {
                        module.dependencies.push(depModulePath);
                    }
                }
            }
        });
        let {code} = generator(ast); // 根据新的语法树生成新的代码
        module._source = code; // 此模块的源代码
        // 递归编译每个依赖项
        module.dependencies.forEach(dependency => {
            let depModule = this.buildModule(entryName, dependency);
            this.modules.add(depModule);
        });
        return module;
    }
}
// 添加文件后缀的方法
function tryExtensions(
    depModulePath, // 拼出来的模块路径 c:/src/title
    extensions, // ['.js', '.jsx', '.json']
    moduleName, // ./title
    dirname, // c:/src
){
    // 有可能本身就已经带了后缀,就没有必要加了
    extensions.unshift(""); // ['', '.js', '.jsx', '.json']
    for(let i =0; i < extensions.length; i++) {
        if(fs.existsSync(depModulePath+extensions[i])){
            return modulePath+extensions[i];
        }
    }
    // 如果执行到了这,说明都没有匹配上
    throw new Error(`module not found,error: can't resolve ${moduleName} in ${dirname}`);
}

function getSource(chunk) {
    return `
    (() => {
        var modules = ({
            ${
                chunk.modules.map(module => `
                    "${module.id}":
                    ((module) => {
                        ${module._source}
                    })
                `).join(",")
            }
        });
        var cache = {};
        function require(moduleId) {
            var cacheModule = cache[moduleId];
            if(cacheModule !== undefined) {
                return cacheModule.exports;
            }
            var module = cache[moduleId] = {esports: {}};
            modules[moduleId](module, module.exports, require);
            return module.exports;
        }
        var exports = {};
        (() => {
            ${chunk.entryModule._source}
        })();
    })();
    `;
}
module.exports  = Compiler;

// utils 工具方法
// 1. 统一路径分隔符,\换成/
// window 路径分隔符 \  mac linux 路径分隔符 /,为了统一
function toUnixPath(filePath) {
    return filePath.replace(/\\/g, "/");
}