俗話說,脫離業務談代碼的都是耍流氓。在此我先簡單介紹下重構項目的背景。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地址瞭解更多。
兩種方案均可行,但各有優缺點。
考慮到運營並不在意那點等待時間,相比之下項目穩定性更重要。因而選擇方案二:「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保存任務
搞定。
前面解決了如何靜態化,那麼如何更新部署呢? 這就涉及到工做流的問題了。
這次構建大體工做流:
簡單描述下圖中流程:
這一步就是開發,沒啥好說。。。
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之類的。
如此一來,新建頁面在新的工做流直接發佈就行,而須要迭代,重構頁面後把老項目裏對應文件重命名或者刪除就行。
因爲本項目 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資源發佈的流程與優化等等。之後有時間有心情再給你們嘮嗑。