diff --git a/packages/@vuepress/core/lib/node/build/index.js b/packages/@vuepress/core/lib/node/build/index.js index 912eca8c23..c661928d3e 100644 --- a/packages/@vuepress/core/lib/node/build/index.js +++ b/packages/@vuepress/core/lib/node/build/index.js @@ -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. @@ -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() @@ -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' }) @@ -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() } /** @@ -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} - * @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 } } @@ -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 ? `` : ``}` -} - -/** - * 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} - */ - -function renderPageMeta (meta) { - if (!meta) return '' - return meta.map(m => { - let res = ` { - 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 diff --git a/packages/@vuepress/core/lib/node/build/worker.js b/packages/@vuepress/core/lib/node/build/worker.js new file mode 100644 index 0000000000..d7edc231bc --- /dev/null +++ b/packages/@vuepress/core/lib/node/build/worker.js @@ -0,0 +1,145 @@ +const { parentPort } = require('worker_threads') +const escape = require('escape-html') +const { chalk, fs, path, logger } = require('@vuepress/shared-utils') +const { createBundleRenderer } = require('vue-server-renderer') +const { normalizeHeadTag } = require('../util/index') +const { version } = require('../../../package') + +/** + * Worker file for HTML page rendering + * + * @param {number} workerNumber + * @param {Array} pages + * @returns {Promise} + * @api private + */ + +parentPort.once('message', async payload => { + logger.setOptions({ logLevel: payload.logLevel }) + const siteConfig = JSON.parse(payload.siteConfig) + const ssrTemplate = JSON.parse(payload.ssrTemplate) + + // create server renderer using built manifests + const renderer = createBundleRenderer(JSON.parse(payload.serverBundle), { + clientManifest: JSON.parse(payload.clientManifest), + runInNewContext: false, + inject: false, + shouldPrefetch: siteConfig.shouldPrefetch || (() => true), + template: await fs.readFile(ssrTemplate, 'utf-8') + }) + + // pre-render head tags from user config + const userHeadTags = (siteConfig.head || []).map(renderHeadTag).join('\n ') + + const pages = JSON.parse(Buffer.from(payload.pages)) + logger.wait(`Worker #${payload.workerNumber} beginning rendering of ${pages.length} pages`) + const filePaths = [] + let pagesRendered = 0 + + for (const page of pages) { + const pagePath = decodeURIComponent(page.path) + + // #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: userHeadTags, + pageMeta, + title: 'VuePress', + lang: 'en', + description: '', + version + } + + let html + try { + html = await renderer.renderToString(context) + } catch (e) { + console.error( + logger.error( + chalk.red( + `Worker #${payload.workerNumber} error rendering ${pagePath}:` + ), + false + ) + ) + throw e + } finally { + const filename = pagePath + .replace(/\/$/, '/index.html') + .replace(/^\//, '') + const filePath = path.resolve(payload.outDir, filename) + await fs.ensureDir(path.dirname(filePath)) + await fs.writeFile(filePath, html) + filePaths.push(filePath) + pagesRendered++ + + if (pagesRendered % 50 === 0) { + parentPort.postMessage({ + complete: false, + message: `Worker #${payload.workerNumber} has rendered ${pagesRendered} of ${pages.length} pages`, + filePaths: null + }) + } + } + } + parentPort.postMessage({ + complete: true, + message: `Worker #${payload.workerNumber} has rendered ${pagesRendered} of ${pages.length} pages`, + filePaths: filePaths + }) +}) + +/** + * 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 head tag + * + * @param {Object} tag + * @returns {string} + */ + +function renderHeadTag (tag) { + const { tagName, attributes, innerHTML, closeTag } = normalizeHeadTag(tag) + return `<${tagName}${renderAttrs(attributes)}>${innerHTML}${ + closeTag ? `` : `` + }` +} + +/** + * Render meta tags + * + * @param {Array} meta + * @returns {Array} + */ + +function renderPageMeta (meta) { + if (!meta) return '' + return meta + .map(m => { + let res = ` { + res += ` ${key}="${escape(m[key])}"` + }) + return res + `>` + }) + .join('') +} diff --git a/packages/docs/docs/api/cli.md b/packages/docs/docs/api/cli.md index b28b783dfc..417963494d 100644 --- a/packages/docs/docs/api/cli.md +++ b/packages/docs/docs/api/cli.md @@ -16,6 +16,9 @@ See [port](../config/README.md#port). ### -t, --temp `` See [temp](../config/README.md#temp). +### -w, --worker `<#>` +Specifies the number of Node.js [worker_threads](https://nodejs.org/api/worker_threads.html) to use. Defaults to `1`. + ### -c, --cache `[cache]` ### --no-cache See [cache](../config/README.md#cache). diff --git a/packages/vuepress/lib/registerCoreCommands.js b/packages/vuepress/lib/registerCoreCommands.js index 2066565e97..62ce053270 100644 --- a/packages/vuepress/lib/registerCoreCommands.js +++ b/packages/vuepress/lib/registerCoreCommands.js @@ -47,17 +47,18 @@ module.exports = function (cli, options) { .option('-d, --dest ', 'specify build output dir (default: .vuepress/dist)') .option('-t, --temp ', 'set the directory of the temporary file') .option('-c, --cache [cache]', 'set the directory of cache') + .option('-w, --workers <#>', 'set the number of worker threads') .option('--dest ', 'the output directory for build process') .option('--no-cache', 'clean the cache before build') .option('--debug', 'build in development mode for debugging') .option('--silent', 'build static site in silent mode') .action((sourceDir = '.', commandOptions) => { - const { debug, silent } = commandOptions + const { debug, silent, workers } = commandOptions logger.setOptions({ logLevel: silent ? 1 : debug ? 4 : 3 }) logger.debug('global_options', options) logger.debug('build_options', commandOptions) - env.setOptions({ isDebug: debug, isTest: process.env.NODE_ENV === 'test' }) + env.setOptions({ isDebug: debug, isTest: process.env.NODE_ENV === 'test', workerThreads: workers || 1 }) wrapCommand(build)({ sourceDir: path.resolve(sourceDir),