2025-07-31
6 min read

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.txtsitemap.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,接下來就部署上去囉.

Reference