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

项目背景

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

截图镇楼:魅族官网首页
魅族官网基于 next.js 重构实践总结与分享_第1张图片

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

此次重构解决了那些痛点

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

以上痛点想必手上有老项目的,都感同身受。改起来伤筋动骨,但不改吧工作效率太低了。
魅族官网基于 next.js 重构实践总结与分享_第2张图片

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

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

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

魅族官网基于 next.js 重构实践总结与分享_第3张图片

此次重构技术选型

首先,服务端渲染 SSR 是没跑了,它可以更快渲染首屏,同时对 SEO 更友好。
魅族官网基于 next.js 重构实践总结与分享_第4张图片

于是我在带着鸭梨与小兴奋寻遍各大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服务

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

魅族官网基于 next.js 重构实践总结与分享_第5张图片

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

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

问题二:如何静态化

如何做呢?

恩... 最简单的就是 cd 到项目目录下 npm run build && npm run export 下,打包出文件到./out文件夹,然后打个zip包扔服务器上。
当然,为了运营数据及时更新,你得24小时不停重复以上步奏,还不能手抖出错。
魅族官网基于 next.js 重构实践总结与分享_第6张图片

为了不被同事打死,我设计了一套开发流程,在项目中写一个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 来维护。

魅族官网基于 next.js 重构实践总结与分享_第7张图片

// 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 重构实践总结与分享_第8张图片

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

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

此次构建大致工作流:
魅族官网基于 next.js 重构实践总结与分享_第9张图片

简单描述下图中流程:

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

魅族官网基于 next.js 重构实践总结与分享_第10张图片
这一步就是开发,没啥好说。。。

二.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 重构实践总结与分享_第11张图片

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

第一个路径值,跟项目里next.js 生成的BUILD_ID内容一致
魅族官网基于 next.js 重构实践总结与分享_第12张图片

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

你会发现,BUILD_ID 值变了。
魅族官网基于 next.js 重构实践总结与分享_第13张图片

魅族官网基于 next.js 重构实践总结与分享_第14张图片

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

魅族官网基于 next.js 重构实践总结与分享_第15张图片

如果翻开 next.js 源码,你会发现 next.js 每次是用一个叫 nanoid 的库随机生成 String 值。
魅族官网基于 next.js 重构实践总结与分享_第16张图片

为什么要这么设计呢?如果 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 搭建一套。

以测试环境的配置为例:
魅族官网基于 next.js 重构实践总结与分享_第17张图片

魅族官网基于 next.js 重构实践总结与分享_第18张图片

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

魅族官网基于 next.js 重构实践总结与分享_第19张图片

问题四:如何兼容旧架构

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

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

以下是 ngxin 配置思维图:
魅族官网基于 next.js 重构实践总结与分享_第20张图片

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之类的。

如此一来,新建页面在新的工作流直接发布就行,而需要迭代,重构页面后把老项目里对应文件重命名或者删除就行。
魅族官网基于 next.js 重构实践总结与分享_第21张图片

如何支持 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 可以渲染出 https://www.meizu.com/accesso...https://www.meizu.com/en/acce...

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

第一步,根据 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 (
      
        
          
        
      
    );
  }
}
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资源发布的流程与优化等等。以后有时间有心情再给大家唠嗑。

魅族官网基于 next.js 重构实践总结与分享_第22张图片

你可能感兴趣的:(服务端渲染,next.js,javascript,node.js,html5)