魅族官網基於 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 可以渲染出 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資源發佈的流程與優化等等。以後有時間有心情再給大家嘮嗑。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章