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

项目背景

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

截图镇楼:魅族官网首页
html

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

这次重构解决了那些痛点

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

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

这次重构须要知足哪些要求

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

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

这次重构技术选型

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

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

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

这里不作过多讲解了,你们能够访问 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 目录,都不存在则返回 40四、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 能够渲染出 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 (
      <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资源发布的流程与优化等等。之后有时间有心情再给你们唠嗑。

相关文章
相关标签/搜索