Isomorphic React(React同构应用)三 :Bundle with Webpack

苏雅珺
2023-12-01

webpack

使用webpack对组件化的前端项目进行打包在如今是比较流行的做法。webpack解决的根本问题是处理项目中各种不同类型资源的依赖关系,并把他们打包成一个或多个文件,这也是我接触webpack的初衷。在webpack之前有seajs、FIS等解决模块化依赖问题的方案,seajs只解决模块引入的问题,FIS在纯前端的环境下显得过于臃肿(或许是我没有太深入了解),webpack的优势在于解决模块化问题的同时,也完成了一部分工作流的功能,把各个模块提前编译并集中起来打包,以前我们可能要使用gulp和grunt来完成这部分工作,现在一个webpack就能解决。同时webpack还提供了热替换、静态资源开发服务器这些解决开发流程的功能,这让webpack看起来很完美。

server中使用webpack

好吧,首先在这里澄清一个观点,本篇使用webpack在服务器端打包只是提供一个解决思路,并不是什么最佳实践。之前就在知乎上看到有人吐槽webpack在做Server-side render/Isomorphic/Universal很坑。为什么这么讲?本来服务端node自带模块化功能,如果在开发过程中避免在node运行的生命周期中使用DOM和BOM对象,我们写的组件应该是能够直接跑在node环境中的。但是考虑到使用webpack的不同资源依赖的功能,情况就不一样了。如果我们在组件中引入了图片资源或者css,不经过webpack的loader进行加载,node是无法直接运行的。

这时我们通常会想到用webpack直接把服务端运行的代码也进行打包,把需要依赖的静态资源用loader提前解析就行了,但是css-loader里面也使用到了document和window,运行失败= =。有一种解决方案是放弃静态资源和组件一并打包,使用gulp和browserify来做构建工具,大概思路可以参考这篇文章《Writing apps with React.js: Build using gulp.js and Browserify》。但是秉着对组件化的执着,也是对webpack更深入使用的探究,我们决定尝试hack掉webpack在node环境下的各种问题。

忽略依赖的内建模块和node_modules

node环境下有许多内建模块,比如fs,path,http这些基础模块,webpack在编译这些模块的时候会报“Moudle not found”。因为webpack只会去当前运行环境目录和设置的resolve.root目录下去寻找,而这些内建模块并不在这些目录下就会报错了。因为node环境下这些模块的依赖能够正确的被解析,所以我们直接忽略解析这些模块就可以了。而node环境中依赖的node_modules模块,有各种各样的问题(会有二进制的依赖模块,比如express),因为他们都能正确地被node引用,所以我们不希望webpack去打包,和之前的内建模块一样,我们都忽略掉。
忽略内建模块webpack提供了对应的配置参数target: node
configs/webpack/server.config.js

var webpack = require('webpack');
var path = require('path');
var fs = require('fs');

var env = require(path.resolve(__dirname,'../environments'));

module.exports = {
  entry: path.resolve(__dirname,'../..','server/server.js'),
  // ignore build-in modules
  target: 'node',
  output: {
    path: path.resolve( __dirname,'../..','dist'),
    filename: 'server.js'
  }
}

忽略node_mouldes中的模块,webpack提供了externals配置对外部环境依赖的功能,这正好能够派上用场。因为我们不是要用一个变量对引用进行替换,而是用使用需要保留require,所以我们在externals中需要保留require的模块名前加上commonjs来实现这个功能,具体可以参考webpack官网的说明。
我们遍历node_mouldes,依次加入到externals中:

var nodeModules = {};
fs.readdirSync('node_modules')
  .filter(function(x) {
    return ['.bin'].indexOf(x) === -1;
  })
  .forEach(function(mod) {
    nodeModules[mod] = 'commonjs ' + mod;
  });
  
module.exports = {
    /** same with above **/
    externals: nodeModules,
    // ...
}

忽略css和less的引用

接下来,到了解决引入样式的问题了,之前说过,由于css-loader会使用dom对象,这在node环境中是行不通的,所以我们需忽略这些引用。webpack提供NormalModuleReplacementPlugin插件来帮助我们替换不同类型的资源,当匹配到是css和less类型的资源时,我们就使用一个空的模块去进行替换。

/** other configs **/
  plugins: [
    new webpack.NormalModuleReplacementPlugin(/\.(css|less)$/, 'noop'),
    new webpack.IgnorePlugin(/\.(css|less)$/),
    new webpack.BannerPlugin('require("source-map-support").install();',
                             { raw: true, entryOnly: false })
  ],
/** other configs **/

这里使用了其他两个插件,IgnorePlugin插件避免做代码分离时,对分离部分引用的css和less文件进行单独解析打包;另外的BannerPlugin是对server打包做source map,这样如果server代码报错的话,提示的错误代码不会显示打包后的代码行数,而是打包前的代码位置。

node环境变量

node环境下有很多有用的变量,比如__dirname、__filename、process这些变量,我们需要告知webpack这些变量的值该如何处理。相关的配置说明在这里。当然,我们也可以使用DefinePlugin插件来自己模拟这些环境变量来对我们的项目进行更好的控制:

/** other configs **/
  process: true,
  __filename: true,
  __dirname: true,
/** other configs **/

完整的配置文件加上了一些图片资源的直接引用处理(注意保证loader配置和客户端配置一致,否则客户端生成的html会和服务器生成的html产生差异,从而导致页面二次渲染):

var webpack = require('webpack');
var path = require('path');
var fs = require('fs');

var env = require(path.resolve(__dirname,'../environments'));

var nodeModules = {};
fs.readdirSync('node_modules')
  .filter(function(x) {
    return ['.bin'].indexOf(x) === -1;
  })
  .forEach(function(mod) {
    nodeModules[mod] = 'commonjs ' + mod;
  });

module.exports = {
  entry: path.resolve(__dirname,'../..','server/server.js'),
  target: 'node',
  output: {
    path: path.resolve( __dirname,'../..','dist'),
    filename: 'server.js'
  },
  module: {
    loaders: [
      {
        test: /\.jsx?$/,
        exclude: /(node_modules|bower_components)/,
        loader: 'babel'
      },
      {
        test: /\.((woff2?|svg)(\?v=[0-9]\.[0-9]\.[0-9]))|(woff2?|svg|jpe?g|png|gif|ico)$/,
        loader: 'url?name=img/[hash:8].[name].[ext]'
      }, 
      {
        test: /\.((ttf|eot)(\?v=[0-9]\.[0-9]\.[0-9]))|(ttf|eot|otf)$/,
        loader: 'url?limit=10000&name=fonts/[hash:8].[name].[ext]'
      }
    ]
  },
  externals: nodeModules,
  plugins: [
    new webpack.NormalModuleReplacementPlugin(/\.(css|less)$/, 'react'),
    new webpack.BannerPlugin('require("source-map-support").install();',
                             { raw: true, entryOnly: false })
  ],
  resolve:{ root:[ env.inProject("app") ],  alias:  env.ALIAS },
  resolveLoader: {root: env.inNodeMod()},
  process: true,
  __filename: true,
  __dirname: true,
  devtool: 'eval-source-map'
}

搭配gulp搭建工作流

现在打包出来的代码已经能够在node环境中运行了。之前也提到webpack并不只是打包工具,所以开发者功能我们也要一并用起来。在搭建我们的开发环境之前,我们先整理一下我们的思路:
现在我们有两份打包过后的代码,一份是需要在客户端运行基于页面入口文件打包的代码,一份是需要在服务器上运行基于服务程序打包的入口,由于基于两个入口打包的配置差异较大,可以使用一个工厂模式来配置,也可以直接使用两份配置代码;
我们需要一份全局的配置文件协调前端代码和后端代码以及开发过程的工作,需要让这份全局配置能够同时在前后端正常工作,又能兼容webpack的使用;
在开发环境中,我们有两份打包过后的代码,如果需要对这两份代码进行热替换操作,怎么保证替换操作之后我们的代码能够正常运行;
在生产环境中,我们怎么去做版本控制,避免发版时出现页面混乱的情况。

首先第一点,因为在打包代码有开发环境配置和生产环境配置不同的,我们使用两份代码的形式来实现,具体实现可以参考末尾列出的实列项目。
第二点我们使用一个配置文件的形式去实现,因为在配置文件中可能会使用到一些node内建模块,而客户端的配置我们没有做node环境的兼容,所以,在客户端的配置文件中,我们用自定义插件DefinePlugin来实现配置的引入。

var env = require(path.resolve(__dirname,'../environments'));

// define by us 
  plugins: [
    new webpack.DefinePlugin({
      '_configs': JSON.stringify(env)
    })
  ]

第三点的重点在这么实现服务端代码的热替换,客户端的热替换可以使用webpack的热替换功能来实现,虽然也会遇上一些麻烦,我们会在之后提到。服务端的热替换实现起来较为困难,我们可以配合gulp、gulp-nodemon和webpack一起实现监听代码修改后->重新打包->重启服务器的工作流,但是这并不是热替换的初衷,在《Live Editing JavaScript with Webpack》这篇文章中有详细说明webpack的热替换功能,并实现了monkey-hot-loader进行后端的热替换,感兴趣的同学可以仔细看看,这里我们就不加以说明了。基于gulp、gulp-nodemon和webpack的实现模式如下:

var gulp = require('gulp'),
  nodemon = require('nodemon'),
  webpack = require('webpack'),
  gutil = require('gulp-util'),
  argv = require('yargs').argv,
  path = require('path'),
  open = require('open'),
  $ = require('gulp-load-plugins')({ camelize: true }),
  runSequence = require('run-sequence'),
  serverConfig = require('./configs/webpack/server.config'),
  webpackConf = require('./configs/webpack/build.config')('production'),
  env = require('./configs/environments');

function onBuild(done) {
  return function(err, stats) {
    if (err) throw new gutil.PluginError('webpack', err)

    gutil.log('[webpack]', stats.toString({
        colors: true
    }))

    gutil.log(argv)
    
    if (done)
      done()
  }
}

gulp.task('clean',  function() {
    var clean = require('gulp-clean')

    return gulp.src(env.inProject("dist"), {
        read: true
    }).pipe(clean())
})

gulp.task('backend:build', function(done) {
  webpack(serverConfig).run(onBuild(done));
});

gulp.task('backend:watch', function() {
  webpack(serverConfig).watch(100, function(err, stats) {
    onBuild()(err, stats);
    nodemon.restart();
  });
});

gulp.task('open', ['nodemon'], function(){
  open(env.DEV_SERVER+"/__components__");
})

gulp.task('nodemon',['backend:watch'], function() {
  nodemon({
    execMap: {
      js: 'node'
    },
    script: path.join(__dirname, 'dist/server'),
    ignore: ['*'],
    watch: ['foo/'],
    ext: 'noop',
    env: { 'NODE_ENV': "development"},
    args: ["--debug"]
  }).on('restart', function() {
    gutil.log('Restarted!');
  });
});

gulp.task('run', ['open']);

gulp.task('pack', function(done) {
    webpack(webpackConf, function(err, stats) {
        if (err) throw new gutil.PluginError('webpack', err)
        gutil.log('[webpack]', stats.toString({
            colors: true
        }))
        gutil.log(argv)
        done()
    })
})

这里不得不说明下,这个工作流加上webpack开发服务器对本地代码的监听(客户端代码的热替换功能)造成的cpu消耗还有比较大的,在进行试验项目的时候,就因为cpu消耗太高,写代码会有很长的延时,后来更新了一下编辑器的版本,情况好转了很多,所以还是强烈建议使用热替换的功能。
最后一点的实现可以配合gulp-load-plugins的sourcemap功能来实现,具体实现可以在webpack打包客户端代码完成后,用gulp-load-plugins生产sourcemap,在服务端比对后输入到页面中就行。

热替换遇到的麻烦

在进行客户端代码热替换时,因为要单独对客户端代码进行监听打包,所以我们使用webpack的webpack dev server来支持对客户端代码独立热替换。在使用webpack开发服务器进行热替换时有个尴尬的问题,因为我们的应用是跑在自己写的服务器上(这里是两个不同域名的服务器),所以热替换发送到webpack开发服务器的请求都跨域了。这里有两个解决方案,一是用webpack dev middleware将开发服务器集中在应用服务器上,二是在让开发服务器支持跨域请求。另外如果使用了css独立打包的话,热替换就无法展现效果了,因为热替换只能替换模块,css独立打包就无法被修改了,所以我们使用webpack hot middleware让每次修改代码都进行页面刷新来更新新的样式文件。

// load native modules
var http = require('http')
var path = require('path')
var util = require('util')

// load 3rd modules
var koa = require('koa')
// 允许跨域
var cors = require('koa-cors')
var router = require('koa-router')()
var serve = require('koa-static')

var routes = require('./components.dev')

// init framework
var app = koa()

app.use(cors())

// global events listen
app.on('error', (err, ctx) => {
    err.url = err.url || ctx.request.url
    console.error(err.stack, ctx)
})

routes(router, app)
app.use(router.routes())

var webpackDevMiddleware = require('koa-webpack-dev-middleware')
var webpack = require('webpack')
var webpackConf = require('../../configs/webpack')
var compiler = webpack(webpackConf)
var config = require('../../configs/webpack-dev')
// 为使用Koa做服务器配置koa-webpack-dev-middleware
app.use(webpackDevMiddleware(compiler, config))

// 为实现HMR配置webpack-hot-middleware
var hotMiddleware = require('webpack-hot-middleware')(compiler)
// Koa对webpack-hot-middleware做适配
app.use(function* (next) {
    yield hotMiddleware.bind(null, this.req, this.res)
    yield next
})

app = http.createServer(app.callback())

app.listen(4001, '127.0.0.1', () => {
    var url = util.format('http://%s:%d', 'localhost', 4001)

    console.log('Listening at %s', url)
})

到这里我们的开发工作流基本搭建完毕了,还有很多细节部分没有讲到(客户端的相关内容都没有概况),但是我写了一个demo,可以参考一下。

终于到了总结

总体来说这篇文章介绍的方法偏向实验性,更多的是想深入了解webpack,多去尝试一些技术。如果是正式引入项目的话,可能使用gulp加上browserify来搭建工作流更为合适。

相关文章

Isomorphic React(React同构应用)一 :Server Render
Isomorphic React(React同构应用)二 :Redux

参考

Backend Apps with Webpack ( series )
Server-Side Rendering with Redux and React-Router

 类似资料: