Webpack 配置多页面国际化应用

# 多入口配置

通过读取文件结构来动态生成入口。务必使用对象写法,键名表示 chunkName

module.exports = {
  entry: collectEntries()
}

function collectEntries() {
  const pagesDir = path.resolve(__dirname, '../src/pages')
  return fs
    .readdirSync(pagesDir)
    .map((pageName) => ({
      chunkName: pageName,
      entryFile: path.join(pagesDir, pageName, 'index.jsx')
    }))
    .reduce(
      (entries, item) => ({
        ...entries,
        [item.chunkName]: item.entryFile
      }),
      {}
    )
}

# 多 html 文件输出

plugins 添加多个 HtmlWebpackPlugin 实例,添加 chunks 配置将只在页面注入当前页面的代码块,不设置 chunks 会注入所有页面代码块

module.exports = {
  plugins: [
    ...collectHtmls()
    // ...
  ]
}

function collectHtmls() {
  const pagesDir = path.resolve(__dirname, '../src/pages')
  const templatesDir = path.resolve(__dirname, '../public/templates')
  const templateOutputsDir = path.resolve(__dirname, '../dist')
  return fs.readdirSync(pagesDir).map((pageName) => {
    return new HtmlWebpackPlugin({
      inject: true,
      chunks: [pageName],
      filename: path.join(templateOutputsDir, `${pageName}.html`),
      template: path.join(templatesDir, `${pageName}.html`)
    })
  })
}

# devServer 配置

多国家网站地址一般为 https://www.xxx.com/locale, 参考苹果官网 (opens new window)微软官网 (opens new window)。所以我们需要处理一下重定向,也就是 devServer 的 historyApiFallback 项,需提前配置支持的国家。

module.exports = {
  devServer: {
    historyApiFallback: {
      rewrites: collectRewrites()
    }
  }
}

const avalibleLocales = '(cn|us|ru|id)'

function collectRewrites() {
  return fs
    .readdirSync(path.resolve(__dirname, '../src/pages'))
    .map((page) => ({
      from: new RegExp(`^/${avalibleLocales}/${page}/?$`, 'i'),
      to: `/${page}.html`
    }))
}

使用 react-routerbasename 需要配置为当前国家

const App = () => {
  return <Router basename={`/${locale}`}>{/*...*/}</Router>
}

# 多语言配置

使用 React-Intl配置多国语言包。为防止 bundle 体积过大,语言包用 code spliting 分割。注意语言包未加载时不要显示页面内容,否则页面会有闪烁。

const Intl = ({ children }) => {
  const [messages, setMessages] = useState()
  const loadLocaleData = useCallback(() => {
    import(`@/languages/${locale}.json`).then(setMessages)
  }, [language])
  useEffect(loadLocaleData, [])
  return messages ? (
    <IntlProvider messages={messages} locale={locale}>
      {children}
    </IntlProvider>
  ) : null
}

# 如何 SEO ?

使用多页面后由服务端渲染每个页面的 title, description, keywords。如果想要更进一步的 SEO 的话,页面内容也需要服务端渲染。

# 方法一

在页面配置结构化数据,结构化数据交给服务端渲染,示例 https://search.google.com/structured-data/ (opens new window)

此种方法仅适用于谷歌,百度可能有不同的解析规则。

# 方法二

页面内容交给服务端渲染,但是渲染后的结果隐藏。在 react-app 中用 dangerouslySetInnerHTML=\{\{ __html: document.getElementById('ssr-home-first-screen') \}\} 设置页面内容。不过可能会被搜索引擎识别为恶意 SEO 而遭到搜索引擎屏蔽。因此这种方法有风险。

<!DOCTYPE html>
<html lang="<%= lang %>">
  <head>
    <meta name="keywords" content="<%= keywords %>" />
    <meta name="description" content="<%= description %>" />
    <title><%= title %></title>
    <style>
      [id^='ssr-'] {
        position: absolute;
        width: 1px; /* Setting this to 0 make it invisible for VoiceOver */
        height: 1px; /* Setting this to 0 make it invisible for VoiceOver */
        padding: 0;
        margin: -1px;
        border: 0;
        clip: rect(0 0 0 0);
        overflow: hidden;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <div id="ssr-home-first-screen"><%= homeFirstScreen %></div>
  </body>
</html>

# 方法三

由服务端渲染页面主体内容,其它部分由客户端渲染。将页面拆分成多个区域,客户端需执行多个 ReactDOM.render Function. 这样不存在被搜索引擎识别为恶意 SEO 的风险,缺点是比第一种方法麻烦。

<!DOCTYPE html>
<html lang="<%= lang %>">
  <head>
    <meta name="keywords" content="<%= keywords %>" />
    <meta name="description" content="<%= description %>" />
    <title><%= title %></title>
  </head>
  <body>
    <div id="root">
      <header></header>
      <aside></aside>
      <div id="ssr-home-first-screen"><%= homeFirstScreen %></div>
      <footer></footer>
    </div>
  </body>
</html>

# 结语

如果网站比较简单,例如新闻、博客类,那么值得用上述方法一试。如果网站比较复杂,例如电商类,那还是老实用 SSR 比较好。