理解webpack

211 阅读7分钟

什么是webpack?

webpack是近期最火的一款模块加载器兼打包工具,它能把各种资源,例如JS(含JSX)、coffee、样式(含less/sass)、图片等都作为模块来使用和处理,它能有Grunt或Gulp所有基本功能。webpack的官网是https://webpack.github.io/,文档地址是https://webpack.github.io/docs,官网对webpack的定义是MODULE BUNDLER,他的目的就是把有依赖关系的各种文件打包成一系列的静态资源

webpack 的优势

其优势主要可以归类为如下几个: webpack 是以commonJS的形式来书写脚本滴,但对 AMD/CMD的支持也很全面,方便旧项目进行代码迁移。 支持很多模块加载器的调用,可以使模块加载器灵活定制,比如babel-loader加载器,该加载器能使我们使用ES6的语法来编写代码;less-loader加载器,可以将less编译成css文件; 开发便捷,能替代部分 grunt/gulp 的工作,比如打包、压缩混淆、图片转base64等。 可以通过配置打包成多个文件,有效的利用浏览器的缓存功能提升性能。

wepback它的目标是是什么?

webpack它能将依赖的模块转化成可以代表这些包的静态文件

将依赖的模块分片化,并且按需加载
解决大型项目初始化加载慢的问题
每一个静态文件都可以看成一个模块
可以整合第三方库
能够在大型项目中运用
可以自定义切割模块的方式

我们为什么需要webpack

去搞清楚webpack做了什么之前,我觉得首先要思考一下我们为什么需要webpack,它究竟解决了什么痛点。想想我们日常搬砖的场景:

1.开发的时候需要一个开发环境,要是我们修改一下代码保存之后浏览器就自动展现最新的代码那就好了(热更新服务)
2.本地写代码的时候,要是调后端的接口不跨域就好了(代理服务)
3.为了跟上时代,要是能用上什么ES678N等等新东西就好了(翻译服务)
4.项目要上线了,要是能一键压缩代码啊图片什么的就好了(压缩打包服务)
5.我们平时的静态资源都是放到CDN上的,要是能自动帮我把这些搞好的静态资源怼到CDN去就好了(自动上传服务)
巴拉巴拉等等服务,那么多你需要的服务,如果你打一个响指,这些服务都有条不紊地执行好,岂不是美滋滋!所以我们需要webpack帮我们去整合那么多服务,而node的出现,赋予了我们去操作系统的能力,这才有了我们今天的幸福(kubi)生活(manong)。
所以我觉得要根据自己的需求来使用webpack,知道自己需要什么样的服务,webpack能不能提供这样的服务,如果可以,那么这个服务应该在构建中的哪个环节被处理。
如果与输入相关的需求,找entry(比如多页面就有多个入口)
如果与输出相关的需求,找output(比如你需要定义输出文件的路径、名字等等)
如果与模块寻址相关的需求,找resolve(比如定义别名alias)
如果与转译相关的需求,找loader(比如处理sass处理es678N)
如果与构建流程相关的需求,找plugin(比如我需要在打包完成后,将打包好的文件复制到某个目录,然后提交到git上)
抽丝剥茧之后,去理解这些的流程,你就能从webpack那一坨坨的配置中,定位到你需求被webpack处理的位置,最后加上相应的配置即可。

webpack搞了很多东西,但最终产出的无非就是经过重重服务处理过的代码,那么这些代码是怎样的呢?

首先我们先来看看入口文件index.js:

console.log('index')
const one = require('./module/one.js')
const two = require('./module/two.js')
one()
two()

配置webpack

每个目录下都必须有一个webpack.config.js,它的作用就好比Gulpfile.js、或者 Gruntfile.js,就是一个项目配置,告诉webpack需要做什么。

首先先贴上一个比较完整的webpack.config.js的代码,再详细介绍:

//详细的webpack.config.js结构分析: var path = require('path'); var webpack = require('webpack'); var HtmlWebpackPlugin = require('html-webpack-plugin'); var TransferWebpackPlugin = require('transfer-webpack-plugin');

module.exports = {
   devtool: 'source-map',//由于打包后的代码是合并以后的代码,不利于排错和定位,只需要在config中添加,这样出错以后就会采用source-map的形式直接显示你出错代码的位置。
   //noParse:[/jquery/],//表示跳过jquery,不对其进行编译,这样可以提高打包的速度
   //页面入口文件配置
   entry: {
       page1: "./src/index.js",
       //page2: ["./src/index.js", "./src/main.js"],支持数组形式,将加载数组中的所有模块,但以最后一个模块作为输出
   },
   //入口文件输出配置
   output: {
       path: "dist/js/page",
       filename: "[name].bundle.js",// page1.bundle.js 和 page2.bundle.js,并存放到 ./dist/js/page 文件夹下。
       publicPath: "/dist/"    //网站运行时的访问路径。
   },
   resolveLoader: {
       //指定默认的loader路径,否则依赖走到上游会找不到loader
       root: path.join(__dirname, 'node_modules'),
       alias: {//给自己写的loader设置别名
           "seajs-loader": path.resolve( __dirname, "./web_modules/seajs-loader.js" )
       }
   },
   //新建一个开发服务器,并且当代码更新的时候自动刷新浏览器。
   devServer: {
       historyApiFallback: true,
       noInfo: true,
       hot: true,
       inline: true,
       progress: true,
       port:9090 //端口你可以自定义
   },
   module: {
       // module.loaders 是最关键的一块配置。它告知 webpack每一种文件都需要使用什么加载器来处理:
       loaders: [
       { test: /\.css$/, loader: 'style-loader!css-loader' },//.css 文件使用 style-loader 和 css-loader 来处理.
       //{ test: /\.css$/, loader: 'style!css' },其他写法1、"-loader"其实是可以省略不写的,多个loader之间用“!”连接起来。
       //{ test: /\.css$/, loaders: ["style", "css"] },其他写法2、用loaders数组形式;
       //.scss 文件使用 style-loader、css-loader 和 sass-loader 来编译处理。
       //在chrome中我们通过sourcemap可以直接调试less、sass源文件文件
       { test: /\.scss$/, loader: 'style!css!sass?sourceMap'},
       { test: /\.less$/, loader: 'style!css!less?sourceMap'},//.less 文件使用 style-loader、css-loader 和 less-loader 来编译处理
       //.js 文件使用babel-loader来编译处理,设置exclude用来排除node_modules这个文件夹中的代码
       { test: /\.js$/, loader: 'babel!jsx',exclude: /node_modules/ }, 
       { test: /\.jsx$/, loader: "jsx-loader?harmony" },//.jsx 文件使用 jsx-loader 来编译处理
       { test: /\.json$/,loader: 'json'},
       //{ test: /\.(png|jpg|jpeg|gif)$/, loader: 'url-loader?limit=8192'}, //图片文件使用 url-loader 来处理,小于8kb的直接转为base64
       {test: /\.(png|jpg|gif|svg)$/,loader: 'url',
           query: {limit: 10000,name: '[name].[ext]?[hash]'}//设置图片名称扩展名
       },
       { test: /\.jade$/, loader: "jade-loader" },//.jade 文件使用 jade-loader 来编译处理
       { test: /\.ejs$/, loader: "ejs-loader" },//.ejs 文件使用 ejs-loader 来编译处理
       { test: /\.handlebars$/, loader: "handlebars-loader" },//.handlebars 文件使用handlebars-loader来编译处理handlebars模板文件
       { test: /\.dot$/, loader: "dot-loader" },//.dot 文件使用 dot-loader 来编译处理dot模板文件
       { test: /\.vue$/, loader: "vue-loader" },//.vue 文件使用 vue-loader 来编译处理
       { test: /\.coffee$/, loader: 'coffee-loader' },//.coffee 文件使用 coffee-loader 来编译处理
       { test: /\.html$/,loader: 'vue-html'},
       { test: /\.woff$/,loader: "url?limit=10000&minetype=application/font-woff"},
       { test: /\.ttf$/,loader: "file"},
       { test: /\.eot$/,loader: "file"},
       { test: /\.svg$/,loader: "file"}
       ]
   },
   //分内置插件和外置插件
   plugins: [
       //使用了一个 CommonsChunkPlugin 的插件,它用于提取多个入口文件的公共脚本部分,然后生成一个common.js来方便多页面之间进行复用。
       new webpack.optimize.CommonsChunkPlugin('common.js'),
       new webpack.optimize.UglifyJsPlugin({//压缩文件
         compressor: {
           warnings: false,//supresses warnings, usually from module minification
         },
         except: ['$super', '$', 'exports', 'require']    //排除关键字(可选)
       }),
       new webpack.DefinePlugin({// definePlugin 接收字符串插入到代码当中, 所以你需要的话可以写上 JS 的字符串
            __DEV__: JSON.stringify(JSON.parse(process.env.BUILD_DEV || 'true')),
            __PRERELEASE__: JSON.stringify(JSON.parse(process.env.BUILD_PRERELEASE || 'false'))
       }),
       new webpack.ProvidePlugin({//把一个全局变量插入到所有的代码中,支持jQuery plugin的使用;使用ProvidePlugin加载使用频率高的模块
            //provide $, jQuery and window.jQuery to every script
            $: "jquery",
            jQuery: "jquery",
            "window.jQuery": "jquery"
        }),
       new webpack.NoErrorsPlugin(), //允许错误不打断程序
       new TransferWebpackPlugin([ //把指定文件夹下的文件复制到指定的目录
         {from: 'www'}
       ], path.resolve(__dirname,"src")),
       new HtmlwebpackPlugin({//用于生产符合要求的html文件;
          title: 'Hello World app',
          filename: 'assets/admin.html'
       })
   ],
   //其它解决方案配置
   resolve: {
       root: 'E:/github/flux-example/src', //绝对路径, 查找module的话从这里开始查找(可选)
       extensions: ['', '.js', '.html', '.css', '.scss'], //自动扩展文件后缀名,意味着我们require模块可以省略不写后缀名
       alias: {                            //模块别名定义,方便后续直接引用别名,无须多写长长的地址//后续直接 require('AppStore') 即可
           AppStore : 'js/stores/AppStores.js',
           ActionType : 'js/actions/ActionType.js',
           AppAction : 'js/actions/AppAction.js'
       },
       modulesDirectories: [//取相对路径,所以比起 root ,所以会多很多路径。查找module(可选)
            'node_modules',
            'bower_components',
            'lib',
            'src'
       ]
   }
    
};

if (process.env.NODE_ENV === 'production') {
 module.exports.devtool = '#source-map'
 // http://vue-loader.vuejs.org/en/workflow/production.html
 module.exports.plugins = (module.exports.plugins || []).concat([
   new webpack.DefinePlugin({
     'process.env': {
       NODE_ENV: '"production"'
     }
   }),
   new webpack.optimize.UglifyJsPlugin({
     compress: {
       warnings: false
     }
   }),
   //为组件分配ID,通过这个插件webpack可以分析和优先考虑使用最多的模块,并为它们分配最小的ID
   new webpack.optimize.OccurenceOrderPlugin()
 ])
 }

简化webpack打包出来的代码

其实进过简化后就可以看到,这些代码意图十分明显,也是我们十分熟悉的套路。

 (function (modules) {
    const require = function (moduleId) {
      const module = {}
      module.exports = null
      modules[moduleId].call(module, module, require)
      return module.exports
    }
    require(0)
})([
function (module, require) {
    console.log('index')
    const one = require(1)
    const two = require(2)
    one()
    two()
},
function (module, require) {
    module.exports = function () {
        console.log('one')
    }
},
function (module, require) {
    module.exports = function () {
        console.log('two')
    }
}])

简单的操作

没有什么比自己撸一个理解得更透彻了。我们根据上面的最终打包的结果来捋一捋要做一些什么事情。 1.观察一下,我们需要一个自执行函数,这里面需要控制的是这个自执行函数的传参,就是那个数组 2.这个数组是毋容置疑是根据依赖关系来形成的 3.我们要找到所有的require然后将require的路径替换成对应数组的索引 4.将这个处理好的文件输出出来 ok,上代码:

 const fs = require('fs')
const path = require('path')
const esprima = require('esprima')
const estraverse = require('estraverse')
// 定义上下文 即所有的寻址都按照这个基准进行
const context = path.resolve(__dirname, '../')
// 处理路径
const pathResolve = (data) => path.resolve(context, data)
// 定义全局数据格式
const dataInfo = {
    // 入口文件源码
    source: '',
    // 分析入口文件源码得出的依赖信息
    requireInfo: null,
    // 根据依赖信息得出的各个模块
    modules: null
}
/**
 * 读取文件
 * @param {String} path 
 */
const readFile = (path) => {
    return new Promise((resolve, reject) => {
        fs.readFile(path, function (err, data) {
            if (err) {
                console.log(err)
                reject(err)
                return
            }
            resolve(data)
        })
    })
}
/**
 * 分析入口源码
 */
const getRequireInfo = () => {
    // 各个依赖的id 从1开始是因为0是入口文件
    let id = 1
    const ret = []
    // 使用esprima将入口源码解析成ast
    const ast = esprima.parse(dataInfo.source, {range: true})
    // 使用estraverse遍历ast
    estraverse.traverse(ast, {
        enter (node) {
            // 筛选出require节点
            if (node.type === 'CallExpression' && node.callee.name === 'require' && node.callee.type === 'Identifier') {
                // require路径,如require('./index.js'),则requirePath = './index.js'
                const requirePath = node.arguments[0]
                // 将require路径转为绝对路径
                const requirePathValue = pathResolve(requirePath.value)
                // 如require('./index.js')中'./index.js'在源码的位置
                const requirePathRange = requirePath.range
                ret.push({requirePathValue, requirePathRange, id})
                id++
            } 
        }
    })
    return ret
}
/**
 * 模块模板
 * @param {String} content 
 */
const moduleTemplate = (content) => `function (module, require) {\n${content}\n},`
/**
 * 获取模块信息
 */
const getModules = async () => {
    const requireInfo = dataInfo.requireInfo
    const modules = []
    for (let i = 0, len = requireInfo.length; i < len; i++) {
        const file = await readFile(requireInfo[i].requirePathValue)
        const content = moduleTemplate(file.toString())
        modules.push(content)
    }
    return modules
}
/**
 * 将入口文件如require('./module/one.js')等对应成require(1)模块id
 */
const replace = () => {
    const requireInfo = dataInfo.requireInfo
    // 需要倒序处理,因为比如第一个require('./module/one.js')中的路径是在源码字符串42-59这个区间
    // 而第二个require('./module/two.js')中的路径是在源码字符串82-99这个区间,那么如果先替换位置较前的代码
    // 则此时源码字符串已经少了一截(从'./module/one.js'变成1),那第二个require的位置就不对了
    const sortRequireInfo = requireInfo.sort((item1, item2) => item1.requirePathRange[0] < item2.requirePathRange[0])
    sortRequireInfo.forEach(({requirePathRange, id}) => {
        const start = requirePathRange[0]
        const end = requirePathRange[1]
        const headerS = dataInfo.source.substr(0, start)
        const endS = dataInfo.source.substr(end)
        dataInfo.source = `${headerS}${id}${endS}`
    })
}
/**
 * 输出打包好的文件
 */
const output = async () => {
    const data = await readFile(pathResolve('./template/indexTemplate.js'))
    const indexModule = moduleTemplate(dataInfo.source)
    const allModules = [indexModule, ...dataInfo.modules].join('')
    const result = `${data.toString()}([\n${allModules}\n])`
    fs.writeFile(pathResolve('./build/output.js'), result, function (err) {
        if (err) {
            throw err;
        }
    })
}
const main = async () => {
    // 读取入口文件
    const data = await readFile(pathResolve('./index.js'))
    dataInfo.source = data.toString()
    // 获取依赖信息
    dataInfo.requireInfo = getRequireInfo()
    // 获取模块信息
    dataInfo.modules = await getModules()                                   
    // 将入口文件如require('./module/one.js')等对应成require(1)模块id
    replace()
    // 输出打包好的文件
    output()
    console.log(JSON.stringify(dataInfo))
}
main()

这里的关键是将入口源码转成ast从而分析出require的路径在源码字符串中所在的位置,我们这里用到了esprima去将源码转成ast,然后用estraverse去遍历ast从而筛选出我们感兴趣的节点,这时我们就可以对转化成ast的代码为所欲为了,babel就是这样的原理为我们转化代码的。