由於項目需要搭建一個node服務器,用來做html模板渲染,以及將渲染結果轉化為pdf或者png。項目已放在GitHub,查看源碼,請點這里。經過一段時間的調研,主要對比了兩個工具。一個是chrome官方提供的無頭瀏覽器node包,puppeteer,另一個是命令行工具wkhtmltopdf。接下來簡單介紹一下兩者的區別和優缺點。
| 工具 | 優點 | 缺點 |
| wkhtmltopdf | 1、使用簡單, 2、占用空間小,使用的是webkit engine渲染, 3、渲染效果較好,對css支持比較友好, |
1、對自定義header不支持domString,只支持url |
| puppeteer | 1、使用簡單,官網直接有api提供 2、渲染效果好,由於使用的是chrome的無頭瀏覽器,所以渲染效果和chrome差不多。 3、chrome官方出品的開源包,所以維護者比較多。 4、直接支持domString渲染。 |
1、要下載chrome瀏覽器,占用空間大, 2、啟動chrome實例比較耗時, |
后面經過使用鏈接池的優化,chrome的耗時成功降下來。由於項目比較看重時間損耗,所以最終選擇puppeteer。接下來切入正題。本文將講述puppeteer的一些優化措施,以及怎么結合egg.js搭建成最終的服務器。
1、開發環境:
egg -v 2.15.1
node.js -v 12.16.3
egg-view-nunjucks -v 2.2.0
puppeteer -v 4.0.0
generic-pool -v 3.7.1
2、項目構建:
建議直接使用egg模板進行初始化
npm init egg --type=simple
接下來直接npm i就可以了。然后安裝我們項目所需要的一些依賴,比如我們的主角puppetter、node的一個promise鏈接池generic-pool、egg的模板渲染引擎egg-view-nunjucks。
npm i generic-pool puppeteer egg-view-nunjucks
egg.js 奉行『約定優於配置』,所以對項目目錄什么的都有嚴格的要求,具體信息請查看官方文檔。下面是我的目錄結構

由於我們引入了模板引擎,所以需要進行一些簡單的配置,首先打開config/plugin.js加上如下代碼:
view: { enable: true, package: 'egg-view' }, nunjucks: { enable: true, package: 'egg-view-nunjucks' }
接下來打開config/config.default.js 加入以下代碼。模板引擎的配置就完成了。
// add your user config here const userConfig = { // myAppName: 'egg', view: { defaultViewEngine: 'nunjucks', defaultExtension: '.html', mapping: { '.html': 'nunjucks' } } };
3、puppeteer的優化方案:
受到一篇關於puppeteer和generic-pool文章的啟發,鏈接點這里,在它的基礎上做了一些改動。主要是因為puppeteer啟動一個chrome實例的時間成本較大。然后每啟動一個頁面也需要時間成本。所以考慮使用鏈接池來減少啟動chrome實例和page頁面的時間消耗,建立一個chrome實例的鏈接池,封裝一個page頁面的鏈接池。每次請求來了以后直接去鏈接池里取出實例和頁面。使用page.setContent()來進行操作,可以大大地節省時間,另外關於chrome的一些啟動參數修改,比如單進程模式運行 Chromium等。好的廢話不多說直接上代碼:
pagePool。js
const puppeteer = require('puppeteer');
const genPool = require('./gen-pool');
const pagePool = async (config, options) => {
const browser = await puppeteer.launch(options)
const factory = {
create: () => {
return browser.newPage()
.then(instance => {
instance.useCont = 0;
return instance
})
},
destroy: (instance) => {
instance.close()
},
validate: (instance) => {
return Promise.resolve(instance)
.then(valid => {
Promise.resolve(valid && (config.maxUses <= 0 || instance.useCont < config.maxUses))
})
}
}
const pool = genPool(factory, config)
pool.closeBrowser = () => {
return browser.close()
}
return pool
}
module.exports = pagePool
genPool。js
const genericPool = require('generic-pool');
const genPool = (factory, config) => {
/**
* 創建一個鏈接池
*/
const pool = genericPool.createPool(factory, config)
const genericAcquire = pool.acquire.bind(pool)
/**
* 消耗次數統計
*/
pool.acquire = () =>
genericAcquire()
.then((instance) => {
instance.useCont += 1;
return instance
})
/**
* 不管調用成功與否,都消耗一次實例
*/
pool.use = fn => {
let resource
return pool.acquire()
.then(res => {
resource = res
return resource
})
.then(fn)
.then(
(res) => {
pool.release(resource)
return res
},
(err) => {
pool.release(resource)
return err
}
)
}
return pool
}
module.exports = genPool
puppeteer-pool.js
const pagePool = require('./page-pool');
const genericPool = require('generic-pool');
/**
* 生成一個puppeteer鏈接池
* @param {Object} [options] 創建池的配置
* @param {Object} [options.poolConfig] 鏈接池的配置參數
* @param {Number} [poolConfig.max = 10] 鏈接池的最大容量
* @param {Number} [poolConfig.min = 2] 鏈接池的最小活躍量
* @param {Boolean} [poolConfig.testOnBorrow = true] 在將 實例 提供給用戶之前,池應該驗證這些實例。
* @param {Boolean} [poolConfig.autoStart = true] 啟動時候是否初始化實例
* @param {Number} [poolConfig.idleTimeoutMillis = 60*60*1000] 實例多久不使用將會被關閉(60分鍾)
* @param {Number} [poolConfig.evictionRunIntervalMillis = 3*60*1000] 多久檢查一次是否在使用實例(3分鍾)
* @param {Object} [options.puppeteerConfig] puppeteer的啟動參數配置
*/
const puppeteerPool = (options = { poolConifg: {}, puppeteerConfig: {} }) => {
const config = {
max: 10,
min: 2,
maxUses: 2048,
testOnBorrow: true,
autoStart: true,
idleTimeoutMillis: 60 * 60 * 1000,
evictionRunIntervalMillis: 3 * 60 * 1000,
...options.poolConfig
}
const launchOptions = {
ignoreHTTPSErrors: true,
headless: true,
pipe: true,
args: [
// '--disabled-3d-apis',
// '--block-new-web-contents',
// '--disable-databases',
'–disable-dev-shm-usage',
// '--disable-component-extensions-with-background-pages',
'–-no-sandbox',
// '--disable-setuid-sandbox',
'–-no-zygote',
'–-single-process',
'--no-first-run',
'--disable-local-storage',
// '--disable-media-session-api',
// '--disable-notifications',
// '--disable-pepper-3d',
'--disabled-gpu'
],
...options.puppeteerConfig
}
const factory = {
create: async() => {
const page = await pagePool(config, launchOptions)
return Promise.resolve(page)
},
destroy: async (instance) => {
if(instance.drain) {
await instance.drain()
.then(() => {
instance.clear()
})
}
instance.closeBrowser()
},
validate: (instance) => {
return Promise.resolve(true)
}
}
const pool = genericPool.createPool(factory, config)
return pool
}
module.exports = puppeteerPool
如上所示,我們封裝了一個page的鏈接池,在page鏈接池的基礎上封裝了一層chrome實例的鏈接池。項目流程是:收到http請求>從鏈接池中取出chrome實例>再從chrome實例的鏈接池中取出一個page鏈接>在這個鏈接中執行你的業務代碼>返回數據。這樣做的好處就是每次請求過來都可以復用之前的page,減少了開銷。當然這樣的缺點就是,你初始化的時候需要一些內存空間去存放這些實例。
4、結合eggjs:
在egg.js中首先啟動服務器時候需要初始化這些實例,創建一個app.js然后在didLoad生命周期中初始化實例:

接下來就是寫你的controller和service進行業務邏輯。基本上已經大功告成,如有問題,歡迎大家留言指正。
