魅族官网基于 next.js 重构实践总结与分享

本文首发于个人博客:

项目背景

俗话说,脱离业务谈代码的都是耍流氓。在此我先简单介绍下重构项目的背景。

截图镇楼:魅族官网首页


在 2015 年,公司前端大佬猫哥基于 FIS3 深度定制开发了一套前端工程体系 mz-fis,该框架经历3年来的网站改版升级需求,都很好的完成了需求任务。 但随着项目越来越大,以及前端技术快速迭代。老项目的痛点越发明显。

此次重构解决了那些痛点

1.随着项目越来越大,前端编译打包流程巨慢。(算上图片视频等资源,仓库有3.9G大小) 2.运营需要经常改动网站内容,由于需要SEO,哪怕改几个字也需要前端打包发布。 3.旧框架的核心还是Jquery,虽然结果3年开发积累了很多组件,但在数据维护、模块化以及开发体验上已经落后了。

以上痛点想必手上有老项目的,都感同身受。改起来伤筋动骨,但不改吧工作效率太低了。


此次重构需要满足哪些要求

再说说重构的基本要求,咱得渐进增强而不是优雅降级。:D

1.支持SEO,也就是说需要服务端渲染。 2.解放前端、测试劳动力,让运营在网站内容管理平台编辑数据后发布,官网及时生效。(不同于传统AJAX,这里数据需要SEO)。 3.支持多国语言。 4.需要新旧框架同存,同域名下无缝对接,要求两套工作流都可以正常工作。(一些不频繁改动的页面,可以不改,减少重构成本)。 5.更快的页面性能、更畅快的开发体验和更好可维护性。



此次重构技术选型

首先,服务端渲染 SSR 是没跑了,它可以更快渲染首屏,同时对 SEO 更友好。


于是我在带着鸭梨与小兴奋寻遍各大SSR方案后,最终选择了 Next.js Next.js 是一个轻量级的 React 服务端渲染应用框架。目前在 github 已获得 4W+ 的 star。

之所以火爆,是因为它有以下优点: 1.默认服务端渲染模式,以文件系统为基础的客户端路由 2.代码自动分隔使页面加载更快 3.简洁的客户端路由(以页面为基础的) 4.以webpack的热替换为基础的开发环境 5.使用React的JSX和ES6的module,模块化和维护更方便 6.可以运行在其他Node.js的HTTP 服务器上 7.可以定制化专属的babel和webpack配置

这里不做过多讲解了,大家可以访问 next.js中文网github地址了解更多。

重构过程中遇到的问题以及解决方案

问题一:网站采用 next.js 的 start 模式服务,还是 export 出静态化文件让 ngxin 做web服务

两种方案都可行,但各有优缺点。



考虑到运营并不在乎那点等待时间,相比之下项目稳定性更重要。于是选择方案二:「export 出静态化文件让 ngxin 做web服务」。

ok~ 选定后要做的就是静态化了。

问题二:如何静态化

如何做呢?

恩... 最简单的就是 cd 到项目目录下 npm run build && npm run export 下,打包出文件到./out文件夹,然后打个zip包扔服务器上。 当然,为了运营数据及时更新,你得24小时不停重复以上步奏,还不能手抖出错。


为了不被同事打死,我设计了一套开发流程,在项目中写一个shell脚本:

#!/bin/bash
echo node版本:$(node -v)
BASEDIR=$(dirname $0)
cd ${BASEDIR}/../
sudo npm run build

while true;
do
    whoami && pwd
    sudo npm run export >/dev/null 2>&1 || continue
    sudo chown -R {服务器用户名} ./out || echo 'chown Err'
    sudo cp -ar ./out/* ./www || echo 'cp Err'
    sudo chown -R {服务器用户名} ./www || echo 'chown Err'
    echo '静态化并复制完毕'
    sleep 15
done

好了,只要执行这段 shell,你的服务器就会cd到项目目录,先build构建项目,然后每间隔15秒构建一次。并输出当前环境和相关信息。

但不停 export 就够了么,显然不是。

我们知道 export 只能更新异步API请求的数据。如果对项目代码做改动,比如新增个页面啥的。那需要重新 npm run build然后再 export。

那就要按顺序完成一下小步骤: 1.kill 循环中的 export 进程; 2.等待服务器 git 拉取完代码,并且npm install 项目依赖; 3.重新 build,并且循环 export;

为了方便管理进程和输出日志,我们可以用 pm2 来维护。



// ecosystem.config.js
const path = require('path')

module.exports = {
  /**
   * Application configuration section
   * http://pm2.keymetrics.io/docs/usage/application-declaration/
   */
  apps: [
    {
      name: 'export_m',
      script: path.resolve(__dirname, 'bin/export_m.sh'),
      env: {
        COMMON_VARIABLE: 'true'
      },
      env_production: {
        NODE_ENV: 'production'
      },
      log_date_format: "YYYY-MM-DD HH:mm:ss"
    }
  ]
}

有 pm2 管理进程,我们只需在git仓库更新,并install之后,执行pm2 startOrRestart ecosystem.config.js就ok拉。

此外,实践中遇到个情况。在性能比较差的服务器上,export 进程时间长了,有可能卡死。对此可以设置linux 定时任务重启进程。当然配置高的服务器可以忽略。

1.进入服务器 输入 crontab -e 2.另起一行,输入*/30 * * * * pm2 startOrRestart {你的项目路径}/ecosystem.config.js 3.wq保存任务

搞定。



问题三:工作流以及 next.js 坑爹 build_id 的解决方案

前面解决了如何静态化,那么如何更新部署呢? 这就涉及到工作流的问题了。

此次构建大致工作流:


简单描述下图中流程:

一.npm run dev 本地开发(资源不压缩,且资源路径都在本地)


这一步就是开发,没啥好说。。。

二.npm run build,并推送资源

npm run build后,资源都被webpack压缩了。 因为设置了CDN,js、css 图片等资源的路径会被 webpack 改成 cdn 绝对地址。那么你需要把对应的资源发布到CDN服务器上。

到这细心的童鞋可能注意到图中有个 更新 BUILD_ID,其实这里隐藏着一个 next.js 不小的坑。

啥坑咧?

我们随便下载一个next.js的官网 demo,在本地 build 后 npm start 一下,然后打开网页看js。


如图,next.js 生成一个长长的路径,下面的main.js 生成了一串hash。

第一个路径值,跟项目里next.js 生成的BUILD_ID内容一致


ok!这时候一切正常,接下来我们不对项目代码做任何修改,重新 build 一次

你会发现,BUILD_ID 值变了。




那么 buildID 和 url 如此善变,会引发什么问题呢? 【1】相同源码下,不同服务器生成的静态资源和引用不一致。风险大。 【2】相同源码下,多次构建内容相同,url 却不同,浪费资源,还让 CDN 缓存意义大打折扣。 【3】开发和测试人员在多服务器部署情况下,不好做版本控制,难以逆向追踪 bug。



如果翻开 next.js 源码,你会发现 next.js 每次是用一个叫 nanoid 的库随机生成 String 值。


为什么要这么设计呢?如果 next.js 生成的所有资源都能像 main.js 一样根据文件内容来 hash 命名,岂不美哉?

为此,我曾经在 next.js github 的相关 issues 上问过作者,得到的答复大概意思是,由于 next.js 服务端渲染的特性,每次 build 需要编译两次,两次编译生命周期有所不同难以映射,所以用随机的id存到 BUILD_ID 里当变量,用来解决编译文件引用和路由问题。

当时作者的意思是,短期内解决不了这个特性。(囧。。。

如何解决这个难题呢?

其实 next.js 官方也考虑到这个情况。你可以在 next.config.js 里重写 build_id。

module.exports = {
  generateBuildId: async () => {
    return 'static_build_id'
  }
}

但这样,ID就写死了,更新迭代无法清客户端缓存。除非你每次发布手动更改 ID 值,这么 low 的做法显然不可取。

本次重构的解决方案是在需要发版本时执行以下操作: 1.把 logId 写入到 ./config/VERSION_ID 文件夹 ---- 这是为了方便不同服务器之间同步ID。因为生产环境没有 git 仓库。

2. 在项目 package.json 里配置 script, "update": "sh ./bin/update_version.sh"。

#!/bin/bash

echo "\033[33m ------- 开始检测 git 仓库状态 ------- \033[0m\n"

git_status=`git status`
git_pull="update your local branch"
git_clean="nothing to commit, working tree clean"


if [[ $git_status =~ $git_pull ]]
then

  echo "\033[31m ------- 请更新你的 git 仓库 ------ \033[0m \n"
  exit

else

  # 把最新版本号写入 VERSION_ID
  git_log=`git log --oneline --decorate`
  ID=${git_log:0:7}

  echo $ID > ./config/VERSION_ID 

  echo "------- 发布静态资源到 测试环境 -------\n"

  npm run deploy

  echo "\033[32m \n------- 版本号已更新为$ID,并成功发布资源到测试环境 -------\033[0m \n"

  echo "\033[32m \n------- 请及时 commit git 仓库,并 push 到远程 -------\033[0m \n"

  exit

fi

2.读取./config/VERSION_ID,然后存入环境变量 BUILD_ID。

#!/bin/bash
BASEDIR=$(dirname $0)
build_id=$(cat ${BASEDIR}/config/VERSION_ID)
echo --------- 编译版本号为 $build_id -----------
export BUILD_ID=$build_id

3.更改 next.config.js 配置为以下,然后 build。

module.exports = {
  generateBuildId: async () => {
    if (process.env.BUILD_ID) {
      return process.env.BUILD_ID
    }
    return 'static_build_id'
  }
}

这样,只要不做npm run update, 在不同服务器下,随便 build 多少次。内容都不会变了。

至于发布平台,本项目使用 jenkins 搭建一套。

以测试环境的配置为例:




如此,只要确保代码更新到 git,登录 jenkins 运行下任务就上测试环境拉。 当然也可以利用插件监听 git 的 push 动作自动执行任务。这个就看个人喜好了。



问题四:如何兼容旧架构

要兼容,至少得满足2点: 1.新架构不影响旧架构功能。即原来的工作流依然可以正常部署。 2.新旧架构在同域名下共存,新架构满足新增页面、迭代页面需求。

作为多页面应用。新旧架构都是用 ngxin 做 web 服务器,那么解决起来也很简单。只需要做好 ngxin 的 config 配置就好了。

以下是 ngxin 配置思维图:


nginx 配置示例

server{
    listen 80;
    listen  443;
    ssl     on;
    ssl_certificate     {crt文件};
    ssl_certificate_key {key文件};
    server_name www.meizu.com;

    root {老架构目录路径}/www.meizu.com;
    index landing.html index.html;
    ssi on;
    ssi_silent_errors on;

    error_log /data/log/nginx/error.log;
    access_log /data/log/nginx/access.log;

    location / {
        try_files $uri $uri/index.html $uri.html @node; 
    }

    location @node {
        proxy_pass http://127.0.0.1:8008;
    }

}

server{
    listen 8008;

    root {新架构目录路径}/www;
    index index.html;

    error_page 500 502 503 504 /500.html;
    error_page 404 /404.html;

    location / {
        try_files $uri $uri/index.html $uri.html 404;
    }

}

这里 80、443 端口进来会先判断第一个 root 目录是否存在对应路由。如果存在则直接响应,如果不存在,则走 8008 服务的 root 目录,都不存在则返回 404、500之类的。

如此一来,新建页面在新的工作流直接发布就行,而需要迭代,重构页面后把老项目里对应文件重命名或者删除就行。


如何支持 i18n (国际化)

由于本项目 95% 图文都托管给数据平台了,类似于 i18next 这样的本地多国语言方案,我们并不需要了。

我们只需要做以下两步: 1.按需将一个产品模板文件,导出成多个不同语言的 html。 2.静态化时,根据不同语言获取对应的数据。

先来解决第一个问题。 next.js 提供了自定义的静态化路由配置。例如:

// next.config.js
module.exports = {
  exportPathMap: async function (defaultPathMap) {
    return {
      '/': { page: '/' },
      '/about': { page: '/about' },
      '/home': { page: '/home' }
    }
  }
}

那么我们就可以获取项目 pages 目录下的文件路径来生成一个 map 表,并对其遍历改造。

/****
 * 规则:
 * 中文页面,会根据 page 目录自动生成路由
 * --------  [mapConfig] ---------
 * key 为产品名
 * [rename] 中文产品更名 (实际目录名以英文为标准)
 * [transform] 产品或页面转化为其他语言
 *
 * --------- [include] ---------
 * [include] 手动追加路由表
 *
 * --------- [exclude] ---------
 * [exclude] 手动删除路由表

*/
const glob = require('glob')

const map = {
  mapConfig: { // 在此编辑产品名称即可
    m6: {
      rename: 'meilan6',
      transform: ['en']
    },
    "16s": {
      transform: ['en']
    },
    "16xs": {
      transform: ['en']
    }
  },
  include: {  // 可以手动新增
    '/': { page: '/' }
  },
  exclude: [] // 可以手动新增
}

/** ------------------  以下为 map 表的格式转换处理   ---------------------- **/

let defaultPathMap = {}

const pathList = glob.sync('./pages/**/!(_)*.js').map(c => c.replace('./pages', '').replace(/\.js$/, '.html'))

const mapConfig = map.mapConfig

pathList.forEach(c => {
  //首页
  if (c === '/' || c === '/index.html') return false

  // 目录下的index.html
  if (/\/index\.html$/.test(c)) {
    defaultPathMap[c] = { page: c.replace(/\/index\.html$/, '') }

    // 目录下的index.html
  } else {
    defaultPathMap[c] = { page: c.replace(/\.html$/, '') }
  }

})

// 这一步是针对产品中英文重命名。比如国内 meilan6,国外为m6,由 customPathMap.js 配置
for (let key in defaultPathMap) {
  let pageName = ''
  for (let configKey in mapConfig) {
    /* eslint-disable */
    const pageReg = new RegExp(`/${configKey}[\/|\.]`)
    /* eslint-enable */
    if (pageReg.test(key)) {
      // step-1 新增中文重命名
      if (mapConfig[configKey].rename !== undefined) {
        pageName = key.replace(pageReg, `/${mapConfig[configKey].rename}/`)
        defaultPathMap[pageName] = defaultPathMap[key]
      }
      //step-2 转变国家
      if (mapConfig[configKey].transform !== undefined && mapConfig[configKey].transform.length > 0) {
        mapConfig[configKey].transform.forEach(c => {
          defaultPathMap[`/${c}${key}`] = { ...defaultPathMap[key], pageLang: c }
        })
      }
      //step-3 删除中文已经被重命名的路由
      if (mapConfig[configKey].rename !== undefined) {
        delete defaultPathMap[key]
      }
    }
  }
}

map.exclude.forEach(c => {
  delete defaultPathMap[c]
})

module.exports = {
  ...map.include,
  ...defaultPathMap
}

如此,通过编辑 mapConfig 对象,会导出一个转化后的 map 表。然后使用它。

// next.config.js
const customPathMap = require('./config/customPathMap')

module.exports = {
  exportPathMap: async function (defaultPathMap) {
    return customPathMap
  }
}

ok,现在一套模板可以渲染出两个 html 了, 比如说 pages/accessory/tw50s.js 可以渲染出 meizu.com/accessory/tw5meizu.com/en/accessory/

那接下来要做的,就是根据语言,获取不同的数据了。

第一步,根据 URL 判断页面的语言。并存入 Redux 的 Store

// pages/_app.js

import 'core-js';
import React from "react"
import { Provider } from "react-redux"
import App, { Container } from "next/app"
import withRedux from "next-redux-wrapper"
import { initStore } from '../store'

class MyApp extends App {
  /**
   * 在 _app.js 初始化国家码
   * 设置全局 store.lang,默认为 cn
   * */
  static async getInitialProps({ Component, ctx }) {

    const countryMap = ['cn', 'en', 'hk', 'es'] // 语言列表
    let lang = 'cn'
    const reg = /\/([a-z]+)\/?/
    const langMatch = ctx.req.url.match(reg) ? ctx.req.url.match(reg)[1] : null
    const langIndex = countryMap.indexOf(langMatch)

    if (langMatch && langIndex !== -1) lang = countryMap[langIndex]
    ctx.store.dispatch({ type: 'LANG_INIT', lang })

    let pageProps
    try {
      pageProps = Component.getInitialProps ? await Component.getInitialProps(ctx) : {}
    } catch (err) {
      pageProps = {}
    }
    return { pageProps };
  }

  render() {
    const { Component, pageProps, store } = this.props;
    return (
      <Container>
        <Provider store={store}>
          <Component {...pageProps} />
        </Provider>
      </Container>
    );
  }
}
export default withRedux(initStore)(MyApp);

第二步,在页面 getInitialProps 生命周期获取当前语言数据。

示例代码:

// pages/accessory/tw50.js

class Index extends React.PureComponent {
  static async getInitialProps(ctx) {
    // 获取页面语言
    const lang = ctx.store.getState().lang

    // 获取数据接口 ID 号,作为参数
    const blockIds = getBlockIds(lang, 'header', 'footer', 'subnav', 'tw50s') 

    let pageData
    try {
      //请求数据
      pageData = await getDmsDataById(blockIds)

    } catch (err) {
      pageData = {
        data: []
      }
    }
    return {
      dmsData: pageData.data, // 数据
      lang
    }
  }
}

哦了~

迟到一年的总结差不多了,虽然关于 next.js 还有不少可说的,比如 webpack 自定义配置,cdn资源发布的流程与优化等等。以后有时间有心情再给大家唠嗑。


发布于 2020-03-17 14:46