Isomorphic React(React同構應用)三 :Bundle with Webpack

weeast發表於2016-12-18

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

相關文章