基于webpack搭建前端工程解决方案探索

关于前端工程

下面是百科关于“软件工程”的名词解释:css

软件工程是一门研究用工程化方法构建和维护有效的、实用的和高质量的软件的学科。html

其中,工程化是方法,是将软件研发的各个链路串接起来的工具。前端

 

对于软件“工程化”,我的觉得至少应当有以下特色:node

  • 有IDE的支持,负责初始化工程、工程结构组织、debug、编译、打包等工做
  • 有固定或者约定的工程结构,规定软件所依赖的不一样类别的资源的存放路径甚至代码的写法等
  • 软件依赖的资源可能来自软件开发者,也有多是第三方,工程化须要集成对资源的获取、打包、发布、版本管理等能力
  • 和其余系统的集成,如CI系统、运维系统、监控系统等

普遍意义上讲,前端也属于软件工程的范畴。react

但前端没有Eclipse、Visual Studio等为特定语言量身打造的IDE。由于前端不须要编译,即改即生效,在开发和调试时足够方便,只须要打开个浏览器便可完成,因此前端通常不会扯到“工程”这个概念。jquery

 

在很长一段时间里,前端很简单,好比下面简单的几行代码就可以成一个可运行前端应用:webpack

<!DOCTYPE html> <html> <head> <title>webapp</title> <link rel="stylesheet" href="app.css"> </head> <body> <h1>app title</h1> <script src="app.js"></script> </body> </html>

但随着webapp的复杂程度不断在增长,前端也在变得很庞大和复杂,按照传统的开发方式会让前端失控:代码庞大难以维护、性能优化难作、开发成本变高。git

感谢Node.js,使得JavaScript这门前端的主力语言突破了浏览器环境的限制能够独立运行在OS之上,这让JavaScript拥有了文件IO、网络IO的能力,前端能够根据须要任意定制研发辅助工具。程序员

一时间出现了以Grunt、Gulp为表明的一批前端构建工具,“前端工程”这个概念逐渐被强调和重视。可是因为前端的复杂性和特殊性,前端工程化一直很难作,构建工具备太多局限性。github

诚如张云龙@fouber所言:

前端是一种特殊的GUI软件,它有两个特殊性:一是前端由三种编程语言组成,二是前端代码在用户端运行时增量安装。

html、css和js的配合才能保证webapp的运行,增量安装是按需加载的须要。开发完成后输出三种以上不一样格式的静态资源,静态资源之间有可能存在互相依赖关系,最终构成一个复杂的资源依赖树(甚至网)。

因此,前端工程,最起码须要解决如下问题:

  • 提供开发所需的一整套运行环境,这和IDE做用相似
  • 资源管理,包括资源获取、依赖处理、实时更新、按需加载、公共模块管理等
  • 打通研发链路的各个环节,debug、mock、proxy、test、build、deploy等

其中,资源管理是前端最须要也是最难作的一个环节。

注:我的觉得,与前端工程化对应的另外一个重要的领域是前端组件化,前者属于工具,解决研发效率问题,后者属于前端生态,解决代码复用的问题,本篇对于后者不作深刻。

前端工程化的探索

前端工程化的建设,早前的作法是使用Grunt、Gulp等构建工具。但本质上它们只是一个任务调度器,将功能独立的任务拆解出来,按需组合运行任务。若是要完成前端工程化,这二者配置门槛很高,每个任务都须要开发者自行使用插件解决,并且对于资源的依赖管理能力太弱。

在国内,百度出品的fis也是一种不错的工程化工具的选择,fis内部也解决了资源依赖管理的问题。因笔者没有在项目中实践过fis,因此不进行更多的评价。

webpack以一种很是优雅的方式解决了前端资源依赖管理的问题,它在内部已经集成了许多资源依赖处理的细节,可是对于使用者而言只须要作少许的配置,再结合构建工具,很容易搭建一套前端工程解决方案。

基于webpack的前端自动化工具,能够自由组合各类开源技术栈(Koa/Express/其余web框架、webpack、Sass/Less/Stylus、Gulp/Grunt等),没有复杂的资源依赖配置,工程结构也相对简单和灵活。

在此以开发一个多页面型webapp为例,给出上面所提出的问题的解决方案。

前端开发环境搭建

主要目录结构

- webapp/               # webapp根目录
  - src/                # 开发目录
    + css/              # sass生成的css资源目录
    + img/              # webapp图片资源目录
    - js/               # webapp js&jsx资源目录
      - components/     # 标准组件存放目录
          - foo/        # 组件foo
            + css/      # 组件foo的样式
            + js/       # 组件foo的逻辑
            + tmpl/     # 组件foo的模板
            index.js    # 组件foo的入口
          + bar/        # 组件bar
      + lib/            # 第三方纯js库
      ...               # 根据项目须要任意添加的代码目录
    + tmpl/             # webapp前端模板资源目录
    a.html              # webapp入口文件a
    b.html              # webapp入口文件b
  - assets/             # 编译输出目录,即发布目录
    + js/               # 编译输出的js目录
    + img/              # 编译输出的图片目录
    + css/              # 编译输出的css目录
    a.html              # 编译输出的入口a
    b.html              # 编译处理后的入口b
  + mock/               # 假数据目录
  app.js                # 本地server入口
  routes.js             # 本地路由配置
  webpack.config.js     # webpack配置文件
  gulpfile.js           # gulp任务配置
  package.json          # 项目配置
  README.md             # 项目说明

这是个经典的前端项目目录结构,项目目结构在必定程度上约定了开发规范。业务开发的同窗只需关注src目录便可,开发时尽量最小化模块粒度,这是异步加载的须要。assets是整个工程的产出,无需关注里边的内容是什么,至于怎么打包和解决资源依赖的,往下看。

本地开发环境

咱们使用开源web框架搭建一个webserver,便于本地开发和调试,以及灵活地处理前端路由,以koa为例,主要代码以下:

// app.js var http = require('http'); var koa = require('koa'); var serve = require('koa-static'); var app = koa(); var debug = process.env.NODE_ENV !== 'production'; // 开发环境和生产环境对应不一样的目录 var viewDir = debug ? 'src' : 'assets'; // 处理静态资源和入口文件 app.use(serve(path.resolve(__dirname, viewDir), { maxage: 0 })); app = http.createServer(app.callback()); app.listen(3005, '0.0.0.0', function() { console.log('app listen success.'); });

运行node app启动本地server,浏览器输入http://localhost:8080/a.html便可看到页面内容,最基本的环境就算搭建完成。

若是只是处理静态资源请求,能够有不少的替代方案,如Fiddler替换文件、本地起Nginx服务器等等。搭建一个Web服务器,它可以很是灵活地配置路由和处理动态请求,能够任意定制开发环境的个性化需求用于提高开发效率,总之local webserver拥有无限的可能。

定制动态请求

咱们的local server是localhost域,在ajax请求时为了突破前端同源策略的限制,本地server需支持代理其余域下的api的功能,即proxy。同时还要支持对未完成的api进行mock的功能。

// app.js
var router = require('koa-router')();
var routes = require('./routes');
routes(router, app);
app.use(router.routes());
 ```

``` js
// routes.js
var proxy = require('koa-proxy');
var list = require('./mock/list');
module.exports = function(router, app) {
    // mock api
    // 能够根据须要任意定制接口的返回
    router.get('/api/list', function*() {
        var query = this.query || {};
        var offset = query.offset || 0;
        var limit = query.limit || 10;
        var diff = limit - list.length;

        if(diff <= 0) {
            this.body = {code: 0, data: list.slice(0, limit)};
        } else {
            var arr = list.slice(0, list.length);
            var i = 0;
            while(diff--) arr.push(arr[i++]);
            this.body = {code: 0, data: arr};
        }
    });
    // proxy api
    router.get('/api/foo/bar', proxy({url: 'http://foo.bar.com'}));
}

webpack资源管理

资源的获取

ECMAScript 6以前,前端的模块化一直没有统一的标准,仅前端包管理系统就有好几个。因此任何一个库实现的loader都不得不去兼容基于多种模块化标准开发的模块。

webpack同时提供了对CommonJS、AMD和ES6模块化标准的支持,对于非前三种标准开发的模块,webpack提供了shimming modules的功能。

受Node.js的影响,愈来愈多的前端开发者开始采用CommonJS做为模块开发标准,npm已经逐渐成为前端模块的托管平台,这大大下降了先后端模块复用的难度。

在webpack配置项里,能够把node_modules路径添加到resolve search root列表里边,这样就能够直接load npm模块了:

// webpack.config.js
resolve: {
    root: [process.cwd() + '/src', process.cwd() + '/node_modules'],
    alias: {},
    extensions: ['', '.js', '.css', '.scss', '.ejs', '.png', '.jpg']
},

<div class="se-preview-section-delimiter"></div>
$ npm install jquery react --save

<div class="se-preview-section-delimiter"></div>
// page-x.js
import $ from 'jquery';
import React from 'react';

资源引用

根据webpack的设计理念,全部资源都是“模块”,webpack内部实现了一套资源加载机制,这与Requirejs、Sea.js、Browserify等实现有所不一样,除了借助插件体系加载不一样类型的资源文件以外,webpack还对输出结果提供了很是精细的控制能力,开发者只须要根据须要调整参数便可:

// webpack.config.js
// webpack loaders的配置示例
...
loaders: [
    {
        test: /\.(jpe?g|png|gif|svg)$/i,
        loaders: [
            'image?{bypassOnDebug: true, progressive:true, \
                optimizationLevel: 3, pngquant:{quality: "65-80"}}',
            'url?limit=10000&name=img/[hash:8].[name].[ext]',
        ]
    },
    {
        test: /\.(woff|eot|ttf)$/i,
        loader: 'url?limit=10000&name=fonts/[hash:8].[name].[ext]'
    },
    {test: /\.(tpl|ejs)$/, loader: 'ejs'},
    {test: /\.js$/, loader: 'jsx'},
    {test: /\.css$/, loader: 'style!css'},
    {test: /\.scss$/, loader: 'style!css!scss'},
]
...

简单解释下上面的代码,test项表示匹配的资源类型,loaderloaders项表示用来加载这种类型的资源的loader,loader的使用能够参考using loaders,更多的loader能够参考list of loaders

对于开发者来讲,使用loader很简单,最好先配置好特定类型的资源对应的loaders,在业务代码直接使用webpack提供的require(source path)接口便可:

// a.js
// 加载css资源
require('../css/a.css');

// 加载其余js资源
var foo = require('./widgets/foo');
var bar = require('./widgets/bar');

// 加载图片资源
var loadingImg = require('../img/loading.png');

var img = document.createElement('img');

img.src = loadingImg;

注意,require()还支持在资源path前面指定loader,即require(![loaders list]![source path])形式:

require("!style!css!less!bootstrap/less/bootstrap.less");
// “bootstrap.less”这个资源会先被"less-loader"处理,
// 其结果又会被"css-loader"处理,接着是"style-loader"
// 可类比pipe操做

require()时指定的loader会覆盖配置文件里对应的loader配置项。

资源依赖处理

经过loader机制,能够不须要作额外的转换便可加载浏览器不直接支持的资源类型,如.scss.less.json.ejs等。

可是对于css、js和图片,采用webpack加载和直接采用标签引用加载,有何不一样呢?

运行webpack的打包命令,能够获得a.js的输出的结果:

webpackJsonp([0], {
    /***/0:
    /***/function(module, exports, __webpack_require__) {

        __webpack_require__(6);

        var foo = __webpack_require__(25);
        var bar = __webpack_require__(26);

        var loadingImg = __webpack_require__(24);
        var img = document.createElement('img');

        img.src = loadingImg;
    },

    /***/6:
    /***/function(module, exports, __webpack_require__) {
        ...
    },

    /***/7:
    /***/function(module, exports, __webpack_require__) {
        ...
    },

    /***/24:
    /***/function(module, exports) {
        ...
    },

    /***/25:
    /***/function(module, exports) {
        ...
    },

    /***/26:
    /***/function(module, exports) {
        ...
    }
});

从输出结果能够看到,webpack内部实现了一个全局的webpackJsonp()用于加载处理后的资源,而且webpack把资源进行从新编号,每个资源成为一个模块,对应一个id,后边是模块的内部实现,而这些操做都是webpack内部处理的,使用者无需关心内部细节甚至输出结果。

上面的输出代码,因篇幅限制删除了其余模块的内部实现细节,完整的输出请看a.out.js,来看看图片的输出:

/***/24:
/***/function(module, exports) {

    module.exports = "data:image/png;base64,...";

    /***/
}

注意到图片资源的loader配置:

{
    test: /\.(jpe?g|png|gif|svg)$/i,
    loaders: [
        'image?...',
        'url?limit=10000&name=img/[hash:8].[name].[ext]',
    ]
}

意思是,图片资源在加载时先压缩,而后当内容size小于~10KB时,会自动转成base64的方式内嵌进去,这样能够减小一个HTTP的请求。当图片大于10KB时,则会在img/下生成压缩后的图片,命名是[hash:8].[name].[ext]的形式。hash:8的意思是取图片内容hushsum值的前8位,这样作可以保证引用的是图片资源的最新修改版本,保证浏览器端可以即时更新。

对于css文件,默认状况下webpack会把css content内嵌到js里边,运行时会使用style标签内联。若是但愿将css使用link标签引入,可使用ExtractTextPlugin插件进行提取。

资源的编译输出

webpack的三个概念:模块(module)、入口文件(entry)、分块(chunk)。

其中,module指各类资源文件,如js、css、图片、svg、scss、less等等,一切资源皆被当作模块。

webpack编译输出的文件包括如下2种:

  • entry:入口,能够是一个或者多个资源合并而成,由html经过script标签引入
  • chunk:被entry所依赖的额外的代码块,一样能够包含一个或者多个文件

下面是一段entry和output项的配置示例:

entry: {
    a: './src/js/a.js'
},
output: {
    path: path.resolve(debug ? '__build' : './assets/'),
    filename: debug ? '[name].js' : 'js/[chunkhash:8].[name].min.js',
    chunkFilename: debug ? '[chunkhash:8].chunk.js' : 'js/[chunkhash:8].chunk.min.js',
    publicPath: debug ? '/__build/' : ''
}

其中entry项是入口文件路径映射表,output项是对输出文件路径和名称的配置,占位符如[id][chunkhash][name]等分别表明编译后的模块id、chunk的hashnum值、chunk名等,能够任意组合决定最终输出的资源格式。hashnum的作法,基本上弱化了版本号的概念,版本迭代的时候chunk是否更新只取决于chnuk的内容是否发生变化。

细心的同窗可能会有疑问,entry表示入口文件,须要手动指定,那么chunk究竟是什么,chunk是怎么生成的?

在开发webapp时,总会有一些功能是使用过程当中才会用到的,出于性能优化的须要,对于这部分资源咱们但愿作成异步加载,因此这部分的代码通常不用打包到入口文件里边。

对于这一点,webpack提供了很是好的支持,即code splitting,即便用require.ensure()做为代码分割的标识。

例如某个需求场景,根据url参数,加载不一样的两个UI组件,示例代码以下:

var component = getUrlQuery('component');

if('dialog' === component) {
    require.ensure([], function(require) {
        var dialog = require('./components/dialog');
        // todo ...
    });
}

if('toast' === component) {
    require.ensure([], function(require) {
        var toast = require('./components/toast');
        // todo ...
    });
}

url分别输入不一样的参数后获得瀑布图:

webpack将require.ensure()包裹的部分单独打包了,即图中看到的[hash].chunk.js,既解决了异步加载的问题,又保证了加载到的是最新的chunk的内容。

假设app还有一个入口页面b.html,那麽就须要相应的再增长一个入口文件b.js,直接在entry项配置便可。多个入口文件之间可能公用一个模块,可使用CommonsChunkPlugin插件对指定的chunks进行公共模块的提取,下面代码示例演示提取全部入口文件公用的模块,将其独立打包:

var chunks = Object.keys(entries);

plugins: [
    new CommonsChunkPlugin({
        name: 'vendors', // 将公共模块提取,生成名为`vendors`的chunk
        chunks: chunks,
        minChunks: chunks.length // 提取全部entry共同依赖的模块
    })
],

资源的实时更新

引用模块,webpack提供了require() API(也能够经过添加bable插件来支持ES6的import语法)。可是在开发阶段不可能改一次编译一次,webpack提供了强大的热更新支持,即HMR(hot module replace)

HMR简单说就是webpack启动一个本地webserver(webpack-dev-server),负责处理由webpack生成的静态资源请求。注意webpack-dev-server是把全部资源存储在内存的,因此你会发如今本地没有生成对应的chunk访问却正常。

下面这张来自webpack官网的图片,能够很清晰地说明moduleentrychunk三者的关系以及webpack如何实现热更新的:

enter0表示入口文件,chunk1~4分别是提取公共模块所生成的资源块,当模块4和9发生改变时,由于模块4被打包在chunk1中,模块9打包在chunk3中,因此HMR runtime会将变动部分同步到chunk1和chunk3中对应的模块,从而达到hot replace。

webpack-dev-server的启动很简单,配置完成以后能够经过cli启动,而后在页面引入入口文件时添加webpack-dev-server的host便可将HMR集成到已有服务器:

...
<body>
    ...
    <script src="http://localhost:8080/__build/vendors.js"></script>
    <script src="http://localhost:8080/__build/a.js"></script>
</body>
...

由于咱们的local server就是基于Node.js的webserver,这里能够更进一步,将webpack开发服务器以中间件的形式集成到local webserver,不须要cli方式启动(少开一个cmd tab):

// app.js
var webpackDevMiddleware = require('koa-webpack-dev-middleware');
var webpack = require('webpack');
var webpackConf = require('./webpack.config');

app.use(webpackDevMiddleware(webpack(webpackConf), {
    contentBase: webpackConf.output.path,
    publicPath: webpackConf.output.publicPath,
    hot: true,
    stats: webpackConf.devServer.stats
}));

启动HMR以后,每次保存都会从新编译生成新的chnuk,经过控制台的log,能够很直观地看到这一过程:

公用代码的处理:封装组件

webpack解决了资源依赖的问题,这使得封装组件变得很容易,例如:

// js/components/component-x.js
require('./component-x.css');

// @see https://github.com/okonet/ejs-loader
var template = require('./component-x.ejs');
var str = template({foo: 'bar'});

function someMethod() {}

exports.someMethod = someMethod;

使用:

// js/a.js
import {someMethod} from "./components/component-x";
someMethod();

正如开头所说,将三种语言、多种资源合并成js来管理,大大下降了维护成本。

对于新开发的组件或library,建议推送到npm仓库进行共享。若是须要支持其余加载方式(如RequireJS或标签直接引入),能够参考webpack提供的externals项。

资源路径切换

因为入口文件是手动使用script引入的,在webpack编译以后入口文件的名称和路径通常会改变,即开发环境和生产环境引用的路径不一样:

// 开发环境
// a.html
<script src="/__build/vendors.js"></script>
<script src="/__build/a.js"></script>

// 生产环境
// a.html
<script src="http://cdn.site.com/js/460de4b8.vendors.min.js"></script>
<script src="http://cdn.site.com/js/e7d20340.a.min.js"></script>

webpack提供了HtmlWebpackPlugin插件来解决这个问题,HtmlWebpackPlugin支持从模板生成html文件,生成的html里边能够正确解决js打包以后的路径、文件名问题,配置示例:

// webpack.config.js
plugins: [
    new HtmlWebpackPlugin({
        template: './src/a.html',
        filename: 'a',
        inject: 'body',
        chunks: ['vendors', 'a']
    })
]

这里资源根路径的配置在output项:

// webpack.config.js
output: {
    ...
    publicPath: debug ? '/__build/' : 'http://cdn.site.com/'
}

其余入口html文件采用相似处理方式。

辅助工具集成

local server解决本地开发环境的问题,webpack解决开发和生产环境资源依赖管理的问题。在项目开发中,可能会有许多额外的任务须要完成,好比对于使用compass生成sprites的项目,因目前webpack还不直接支持sprites,因此还须要compass watch,再好比工程的远程部署等,因此须要使用一些构建工具或者脚本的配合,打通研发的链路。

由于每一个团队在部署代码、单元测试、自动化测试、发布等方面作法都不一样,前端须要遵循公司的标准进行自动化的整合,这部分不深刻了。

附上笔者根据本篇的理论所完成的一个前端自动化解决方案项目模板:
webpack-bootstrap

全能程序员交流QQ群290551701,群内程序员都是来自,百度、阿里、京东、小米、去哪儿、饿了吗、蓝港等高级程序员 ,拥有丰富的经验。加入咱们,直线沟通技术大牛,最佳的学习环境,了解业内的一手的资讯。若是你想结实大牛,那 就加入进来,让大牛带你超神!

相关文章
相关标签/搜索