Pre-render html by vue-cli 5
為了讓 google 能檢索網站的指定頁面資訊,但因網站為SPA,不利於SEO,故透過配置 robots.txt 讓 google bot 了解網站資訊,又因網站路由為 hash mode,google bot無法取得其內容,所以在 robots.txt 內設定 Sitemap,讓google bot知道網站頁面的位置,就可以檢索網站指定內容.
robots.txt必須放在域名之下,eg: https://{domain}/robots.txt,此為google的規範,故專案建置後需多輸出兩個檔案 robots.txt、sitemap.xml.
sitemap.xml 設置格式大致如下:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.example.com/home/index.html</loc>
<lastmod>2025-06-04</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
</urlset>
接下來就是要產生 html 讓 sitemap.xml 可以配置,專案為 vue2 且 cli 內的 webpack 版本是 v5.100.2 ,本來打算使用 prerender-spa-plugin,但沒想到直接噴了錯誤,查了一下發現是 node_modules\prerender-spa-plugin\es6\index.js:60 裡面噴了一個錯誤,需要把compilerFS.mkdirp函数改成compilerFS.mkdir才能正常,原因是依賴模組mkdirp-promise已經棄用也用不了,但webpack 4版是可用的,然後找到一個修正的版本 prerender-spa-plugin-next,配置類似 prerender-spa-plugin 些許不一樣而已.
使用 prerender-spa-plugin 建置的錯誤內容:
ERROR Failed to compile with 1 error
error [prerender-spa-plugin] Unable to prerender all routes!
ERROR Error: Build failed with errors.
vue.config.js 配置 prerender-spa-plugin-next
const PrerenderSPAPlugin = require('prerender-spa-plugin-next')
const Renderer = require("@prerenderer/renderer-puppeteer") // 透過 puppeteer 去把渲染後的結果輸出
module.exports = defineConfig({
...
configureWebpack: {
plugins: [
new PrerenderSPAPlugin({
indexPath: "index.html", // 看源碼發現不可使用 __dirname 去合併路徑, 因內部透過express把server啟動後,若透過 '/' 進入網站,會用 'http://localhost:xxx/{indexPath}'來取得 html
staticDir: path.join(__dirname, "dist"),
routes: ["/home"], // 這裡配置要輸出html的路由,會在dist內生成'home'資料夾並且把炫染後的html放到該位置,eg: 'dist/home/index.html'
// 自定義觸發預渲染的事件
// SPA根元素掛載之後 document.dispatchEvent(new Event("prerender"))拋出 prerender 事件觸發預渲染
renderer: new Renderer({ renderAfterDocumentEvent: "prerender" }),
postProcess(renderedRoute) {
// 移除 script tag & replace assets to previous path
renderedRoute.html = renderedRoute.html.replace(/<script .+><\/script>/gmi, '')
renderedRoute.html = renderedRoute.html.replace(/(src|href)="/gmi, '$1="../')
return renderedRoute
}
}),
]
})
接下來在home頁面的mounted事件設定事件觸發,然後執行 npm run build 就會開始產生預渲染的html
<script>
export default {
...
mounted() {
document.dispatchEvent(new Event('prerender')) // 告訴 puppeteer 渲染完成了
},
}
</script>
然後為了讓sitemap.xml內容的lastmod可以每次建制都自動刷新建置時間,並練習一下如何寫一個 webpack plugin,就寫了一個可在建置後替換內容的插件.
sitemap.xml格式如下
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.xxx1.com/home/index.html</loc>
<lastmod>{buildDate}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://www.xxx2.com/home/index.html</loc>
<lastmod>{buildDate}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
</urlset>
透過 thisCompilation 在初始化編譯時,設定 processAssets 鉤子來把建置後的指定資源檔內容替換掉.
vue.config.js 配置自定義的插件:
const ContentReplacePlugin = require('./plugins/ContentReplace')
const now = new Date()
const buildDate = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`
module.exports = defineConfig({
...
configureWebpack: {
plugins: [
new ContentReplacePlugin([
{ fileName: 'sitemap.xml', pattern: '{buildDate}', replaceValue: buildDate }
])
]
})
自定義插件的內容:
/**
* @param {{ pattern: Regexp | string | (content: string, replaceValue: string) => string, fileName: string, replaceValue: string }} rule
* @param {string} content
*/
function applyReplace(rule, content) {
if (typeof rule.pattern === 'function') {
return rule.pattern(content, rule.replaceValue)
}
if (typeof rule.pattern === 'string') {
let result = content.replace(rule.pattern, rule.replaceValue)
while (result.includes(rule.pattern)) {
result = result.replace(rule.pattern, rule.replaceValue)
}
return result
}
return content.replace(rule.pattern, rule.replaceValue)
}
class ContentReplacePlugin {
/**
* @param {({ pattern: Regexp | (content: string, replaceValue: string) => string, fileName: string, replaceValue: string })[]} rules
*/
constructor(rules) {
if (!rules?.length) {
throw new Error('must be setup the match rules!')
}
this.rules = rules
}
apply(compiler) {
const pluginName = this.constructor.name
const rules = this.rules
const { webpack } = compiler
const {
Compilation,
sources: { RawSource }
} = webpack
// console.log('webpack', webpack)
// console.log('RawSource', RawSource)
compiler.hooks.thisCompilation.tap(pluginName, compilation => {
compilation.hooks.processAssets.tap(
{
name: pluginName,
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
},
assets => {
// find match resource
const replaceFiles = Object.keys(assets).reduce(
(result, fileName) => {
const rule = rules.find(rule => rule.fileName === fileName)
if (rule) {
result.push({ fileName, rule })
}
return result
},
[]
)
// console.log('replaceFiles', replaceFiles)
for (const { fileName, rule } of replaceFiles) {
const originSource = assets[fileName]
const content = originSource.source().toString()
if (content) {
// replace origin content
const result = applyReplace(rule, content)
// replace origin source
compilation.updateAsset(fileName, originSource => {
return new RawSource(result)
})
}
}
}
)
})
}
}
exports.default = ContentReplacePlugin
module.exports = exports.default
module.exports.default = exports.default
執行 npm run build 後就會將 sitemap.xml 內的 {buildDate} 替換成建置時的日期,並且輸出 robots.txt與 /home/index.html,接下來就部署上去囉.