Skip to content

feat($core): Improve build performance with Node's worker threads #2189

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 118 additions & 131 deletions packages/@vuepress/core/lib/node/build/index.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
'use strict'
`use strict`

const EventEmitter = require('events').EventEmitter
const webpack = require('webpack')
const readline = require('readline')
const escape = require('escape-html')

const { chalk, fs, path, logger, env, performance } = require('@vuepress/shared-utils')
const { Worker } = require('worker_threads')

const {
chalk,
fs,
path,
logger,
env,
performance
} = require('@vuepress/shared-utils')
const createClientConfig = require('../webpack/createClientConfig')
const createServerConfig = require('../webpack/createServerConfig')
const { createBundleRenderer } = require('vue-server-renderer')
const { normalizeHeadTag, applyUserWebpackConfig } = require('../util/index')
const { version } = require('../../../package')
const { applyUserWebpackConfig } = require('../util/index')

/**
* Expose Build Process Class.
Expand All @@ -33,7 +38,9 @@ module.exports = class Build extends EventEmitter {

async process () {
if (this.context.cwd === this.outDir) {
throw new Error('Unexpected option: "outDir" cannot be set to the current working directory')
throw new Error(
'Unexpected option: "outDir" cannot be set to the current working directory'
)
}

this.context.resolveCacheLoaderOptions()
Expand All @@ -51,36 +58,33 @@ module.exports = class Build extends EventEmitter {

async render () {
// compile!
performance.start()
const stats = await compile([this.clientConfig, this.serverConfig])
const serverBundle = require(path.resolve(this.outDir, 'manifest/server.json'))
const clientManifest = require(path.resolve(this.outDir, 'manifest/client.json'))
const serverBundle = require(path.resolve(
this.outDir,
'manifest/server.json'
))
const clientManifest = require(path.resolve(
this.outDir,
'manifest/client.json'
))

// remove manifests after loading them.
await fs.remove(path.resolve(this.outDir, 'manifest'))

// ref: https://github.com/vuejs/vuepress/issues/1367
if (!this.clientConfig.devtool && (!this.clientConfig.plugins
|| !this.clientConfig.plugins.some(p =>
p instanceof webpack.SourceMapDevToolPlugin
|| p instanceof webpack.EvalSourceMapDevToolPlugin
))) {
if (
!this.clientConfig.devtool
&& (!this.clientConfig.plugins
|| !this.clientConfig.plugins.some(
p =>
p instanceof webpack.SourceMapDevToolPlugin
|| p instanceof webpack.EvalSourceMapDevToolPlugin
))
) {
await workaroundEmptyStyleChunk(stats, this.outDir)
}

// create server renderer using built manifests
this.renderer = createBundleRenderer(serverBundle, {
clientManifest,
runInNewContext: false,
inject: false,
shouldPrefetch: this.context.siteConfig.shouldPrefetch || (() => true),
template: await fs.readFile(this.context.ssrTemplate, 'utf-8')
})

// pre-render head tags from user config
this.userHeadTags = (this.context.siteConfig.head || [])
.map(renderHeadTag)
.join('\n ')

// if the user does not have a custom 404.md, generate the theme's default
if (!this.context.pages.some(p => p.path === '/404.html')) {
this.context.addPage({ path: '/404.html' })
Expand All @@ -89,22 +93,85 @@ module.exports = class Build extends EventEmitter {
// render pages
logger.wait('Rendering static HTML...')

let activeWorkers = 0
const pagePaths = []
for (const page of this.context.pages) {
pagePaths.push(await this.renderPage(page))
}
const pagesPerThread = this.context.pages.length / env.workerThreads

for (let workerNumber = 0; workerNumber < env.workerThreads; workerNumber++) {
const startIndex = workerNumber * pagesPerThread
const pageData = this.context.pages.slice(
startIndex,
startIndex + pagesPerThread
)
const pages = pageData.map(p => ({
path: p.path,
frontmatter: JSON.stringify(p.frontmatter)
}))

const payload = {
clientManifest: JSON.stringify(clientManifest),
outDir: this.outDir,
pages: Buffer.from(JSON.stringify(pages)),
serverBundle: JSON.stringify(serverBundle),
siteConfig: JSON.stringify(this.context.siteConfig),
ssrTemplate: JSON.stringify(this.context.ssrTemplate),
workerNumber,
logLevel: logger.options.logLevel
}

readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
const worker = new Worker(path.join(__dirname, './worker.js'))
worker.postMessage(payload)
activeWorkers++
worker.on('message', response => {
if (response.complete) {
pagePaths.concat(response.filePaths)
}
if (response.message) {
logger.wait(response.message)
}
})
worker.on('error', error => {
// readline.cursorTo(process.stdout, 0, i)
// readline.clearLine(process.stdout, 0)
console.error(
logger.error(
chalk.red(`Worker #${workerNumber} sent error: ${error}\n\n${error.stack}`),
false
)
)
})
worker.on('exit', code => {
activeWorkers--
// readline.cursorTo(process.stdout, 0, i)
// readline.clearLine(process.stdout, 0)
if (code === 0) {
logger.success(`Worker ${workerNumber} completed successfully.`)
} else {
logger.error(
chalk.red(`Worker #${workerNumber} sent exit code: ${code}`),
false
)
}
if (activeWorkers === 0) {
// DONE.
readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
const relativeDir = path.relative(this.context.cwd, this.outDir)
logger.success(
`Generated static files in ${chalk.cyan(relativeDir)}.`
)
const { duration } = performance.stop()
logger.success(
`It took a total of ${chalk.cyan(
`${duration}ms`
)} to run the ${chalk.cyan('vuepress build')}.`
)
console.log()
}
})
}

await this.context.pluginAPI.applyAsyncOption('generated', pagePaths)

// DONE.
const relativeDir = path.relative(this.context.cwd, this.outDir)
logger.success(`Generated static files in ${chalk.cyan(relativeDir)}.`)
const { duration } = performance.stop()
logger.developer(`It took a total of ${chalk.cyan(`${duration}ms`)} to run the ${chalk.cyan('vuepress build')}.`)
console.log()
}

/**
Expand All @@ -119,51 +186,17 @@ module.exports = class Build extends EventEmitter {

const userConfig = this.context.siteConfig.configureWebpack
if (userConfig) {
this.clientConfig = applyUserWebpackConfig(userConfig, this.clientConfig, false)
this.serverConfig = applyUserWebpackConfig(userConfig, this.serverConfig, true)
}
}

/**
* Render page
*
* @param {Page} page
* @returns {Promise<string>}
* @api private
*/

async renderPage (page) {
const pagePath = decodeURIComponent(page.path)
readline.clearLine(process.stdout, 0)
readline.cursorTo(process.stdout, 0)
process.stdout.write(`Rendering page: ${pagePath}`)

// #565 Avoid duplicate description meta at SSR.
const meta = (page.frontmatter && page.frontmatter.meta || []).filter(item => item.name !== 'description')
const pageMeta = renderPageMeta(meta)

const context = {
url: page.path,
userHeadTags: this.userHeadTags,
pageMeta,
title: 'VuePress',
lang: 'en',
description: '',
version
}

let html
try {
html = await this.renderer.renderToString(context)
} catch (e) {
console.error(logger.error(chalk.red(`Error rendering ${pagePath}:`), false))
throw e
this.clientConfig = applyUserWebpackConfig(
userConfig,
this.clientConfig,
false
)
this.serverConfig = applyUserWebpackConfig(
userConfig,
this.serverConfig,
true
)
}
const filename = pagePath.replace(/\/$/, '/index.html').replace(/^\//, '')
const filePath = path.resolve(this.outDir, filename)
await fs.ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, html)
return filePath
}
}

Expand Down Expand Up @@ -197,52 +230,6 @@ function compile (config) {
})
}

/**
* Render head tag
*
* @param {Object} tag
* @returns {string}
*/

function renderHeadTag (tag) {
const { tagName, attributes, innerHTML, closeTag } = normalizeHeadTag(tag)
return `<${tagName}${renderAttrs(attributes)}>${innerHTML}${closeTag ? `</${tagName}>` : ``}`
}

/**
* Render html attributes
*
* @param {Object} attrs
* @returns {string}
*/

function renderAttrs (attrs = {}) {
const keys = Object.keys(attrs)
if (keys.length) {
return ' ' + keys.map(name => `${name}="${escape(attrs[name])}"`).join(' ')
} else {
return ''
}
}

/**
* Render meta tags
*
* @param {Array} meta
* @returns {Array<string>}
*/

function renderPageMeta (meta) {
if (!meta) return ''
return meta.map(m => {
let res = `<meta`
Object.keys(m).forEach(key => {
res += ` ${key}="${escape(m[key])}"`
})
return res + `>`
}).join('')
}

/**
* find and remove empty style chunk caused by
* https://github.com/webpack-contrib/mini-css-extract-plugin/issues/85
Expand Down
Loading