第一次在掘金上發佈文章,本着學習的態度,將本身運用Next.js開發服務端渲染的項目復原總結出來,鞏固知識點,也能夠跟同行探討下技術。(文章不斷完善中...)php
公司原有項目基於PHP和jQuery混合開發的,提出重構需求。可是後端技術棧由PHP更替爲Java微服務,前端技術棧也從jQuery更替爲React.js。由於公司所處行業須要作線上推廣,那項目重構必須得考慮對SEO優化友好了,本身平時更多的是用React.js技術棧作前端開發,因而找到了Next.js(基於React)這個服務端渲染框架。css
中文官網 Next.js 是一個輕量級的 React 服務端渲染應用框架。 服務端渲染的理解:其實不少人接觸過服務端渲染,最傳統的PHP嵌套靜態html頁面就是服務端渲染的一種。PHP經過模板引擎把從數據庫取到的數據渲染到html種,當前端訪問指定路由時,php發送給前臺指定的頁面,這個頁面在瀏覽器端識別到的是.html 文件(Content-type:text/html),瀏覽器按照靜態html文件格式解析頁面渲染後展現出來,用瀏覽器查看源代碼時就是豐富的html標籤還有標籤裏的文本信息,例如SEO信息,文章標題/內容等。這樣的頁面搜索引擎就能夠很容易抓取到了。Next.js 原理相似,只不事後端的語言是Node而已,在React組件中嵌入getInitialProps方法獲取到的服務端動態數據,在服務端把React組件渲染成html頁面,發送到前臺。html
Next文件系統規定,在pages文件夾下每一個*.js 文件將變成一個路由,自動處理和渲染前端
新建 ./pages/index.js 到你的項目中, 項目運行後能夠經過 localhost:3000/index 路徑訪問到頁面。同理 ./pages/second.js 能夠經過localhost:3000/second訪問到node
如圖片,字體,js工具類react
在根目錄下新建文件夾叫static。代碼能夠經過/static/來引入相關的靜態資源 不要自定義靜態文件夾的名字,只能叫static ,由於只有這個名字 Next.js 纔會把它看成靜態資源webpack
export default () => <img src="/static/my-image.png" alt="my image" />
複製代碼
Next.js 能實現服務端渲染的關鍵點就在這裏了。getInitialProps函數提供獲取數據的生命週期鉤子git
建立一個有狀態、生命週期或有初始數據的 React 組件github
import React from 'react'
export default class extends React.Component {
static async getInitialProps({ req }) {
const userAgent = req ? req.headers['user-agent'] : navigator.userAgent
return { userAgent } // 這裏綁定userAgent數據到Props,組件裏就能夠用 this.props.userAgent訪問到了
}
render() {
const { userAgent } = this.props // ES6解構賦值
return (
<div>
Hello World {userAgent}
</div>
)
}
}
==========================================
// 無狀態組件定義getInitialProps *這種方式也只能用在pages目錄下
const Page = ({ stars }) =>
<div>
Next stars: {stars}
</div>
Page.getInitialProps = async ({ req }) => {
const res = await fetch('https://api.github.com/repos/zeit/next.js')
const json = await res.json()
return { stars: json.stargazers_count }
}
export default Page
複製代碼
上面代碼經過異步方法 getInitialProps 獲取數據,綁定在props。服務渲染時,getInitialProps將會把數據序列化,就像JSON.stringify。頁面初始化加載時,getInitialProps只會加載在服務端。只有當路由跳轉(Link組件跳轉或 API 方法跳轉)時,客戶端纔會執行getInitialPropsweb
劃重點:getInitialProps將不能使用在子組件中。只能使用在pages頁面中 子組件能夠經過pages文件夾下的頁面獲取數據,而後Props傳值到子組件
getInitialProps入參對象的屬性以下:
若是須要注入pathname, query 或 asPath到你組件中,你可使用withRouter高階組件
// pages/index.js
import Link from 'next/link'
export default () =>
<div>
Click{' '}
<Link href="/about">
<a>here</a>
</Link>{' '}
to read more
</div>
// 高階組件
import { withRouter } from 'next/router'
const ActiveLink = ({ children, router, href }) => {
const style = {
marginRight: 10,
color: router.pathname === href? 'red' : 'black'
}
const handleClick = (e) => {
e.preventDefault()
router.push(href)
}
return (
<a href={href} onClick={handleClick} style={style}>
{children}
</a>
)
}
export default withRouter(ActiveLink)
複製代碼
從這一步開始就是實際建立項目寫代碼的過程了,因爲是公司項目,這裏所有用模擬數據,可是上文提到的項目需求都會從零開始一項項實現。
1.首先新建目錄 ssr 在ssr目錄下執行
cnpm install --save next react react-dom // 須要設置 npm鏡像
2.執行完命令後目錄下出現文件夾node_module 和文件package.json
ssr
-node_modules
-package.json
package.json 文件內容以下
{
"dependencies": {
"next": "^8.1.0",
"react": "^16.8.6",
"react-dom": "^16.8.6"
}
}
3.添加腳本到package.json文件. 咱們能夠在這裏自定義npm腳本命令
{
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^8.1.0",
"react": "^16.8.6",
"react-dom": "^16.8.6"
}
}
4.在ssr目錄下新建文件夾 pages | static | components ... ,而後在pages下新建文件 index.js,文件內容以下
export default () => <div>Welcome to next.js!</div>
最終新目錄結構以下 [暫時沒提到的文件和目錄後續會講到]
ssr
-node_modules
-package.json
-components
-static
-imgs
-logo.png
-fonts
-example.ttf
-utils
-index.js
-pages
-index.js
-about.js
-.gitignore
-README.md
-next.config.js
-server.js
5.運行 npm run dev 命令並打開 http://localhost:3000
執行npm run start 以前須要先執行 npm run build 否則會報錯
複製代碼
用瀏覽器調試工具打開查看源代碼,能夠看到 根容器_next 下有div元素渲染進去了,數據不少時就會有豐富的利於搜索引擎爬取html代碼。
這裏跟SPA單頁面應用對比更好理解,SPA應用只有一個掛載組件的root根容器。容器裏面不會看到其餘豐富的html代碼
項目是爲了利於SEO作的服務端渲染,說到SEO,須要設置html文檔裏的head頭部信息。這裏有三個很是關鍵的信息,kywords | description | title 分別表示當前網頁的關鍵字,描述,網頁標題。搜索引擎會根據這幾個標籤裏的內容爬取網頁的關鍵信息,而後用戶在搜索的時候根據這些關鍵字匹配程度作搜索結果頁面展示。(固然展示算法遠遠不止參考這些信息,頁面標籤的語意化,關鍵字密度,外鏈,內鏈,訪問量,用戶停留時間...)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="keywords" content="Winyh | Next.js | React.js | Node.js | ...">
<meta name="description" content="這是一個跟next.js服務端相關的頁面">
<title>基於React.js 技術棧的服務端渲染框架Next.js 實戰記錄</title>
</head>
<body>
</body>
</html>
複製代碼
這個實現了,搜索引擎搜錄也算是簡單實現了。要實現搜索引擎友好其實有上述不少方面的能夠優化。
// components/Common/HeadSeo.js 文件裏代碼以下
import Head from 'next/head'
export default () =>
<Head>
<meta charSet="UTF-8"> // 注意這裏的charSet大寫,否則React jsx語法 會報錯
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="keywords" content="Winyh | Next.js | React.js | Node.js | ...">
<meta name="description" content="這是一個跟next.js服務端相關的頁面">
<title>基於React.js 技術棧的服務端渲染框架Next.js 實戰記錄</title>
</Head>
// pages/index.js 文件裏代碼以下
import Layout from "../components/Layouts/PcLayout"
export default () =>
<Layout>
<div>Welcome to next.js!</div>
</Layout>
相應目錄結構爲
ssr
-node_modules
-package.json
-components
-Common // 公共組件
-HeadSeo.js
-Layouts // 佈局文件
-PcLayout.js
-MLayout.js
-static // 靜態資源
-imgs
-logo.png
-fonts
-example.ttf
-utils
-index.js
-pages
-index.js
-about.js
-.gitignore
-README.md
-next.config.js // 配置文件
-server.js // 服務端腳本
複製代碼
打開localhost:3000 能夠看到相關 head 頭部seo信息已經渲染出來了。若是須要在服務端動態渲染數據,能夠在pages目錄下的文件請求後臺數據,經過Props傳值的方式渲染到HeadSeo文件中,這裏暫時值說下方法,後續寫實際代碼實現。
經過自定義服務端路由實現路由自定義美化功能。例如在武漢(wuhan)站點時,訪問首頁須要路由是這樣的
城市 | 首頁 | 關於咱們 |
---|---|---|
武漢 | /wuhan/index | /wuhan/about |
上海 | /shanghai/index | /shanghai/about |
南京 | /nanjing/index | /nanjing/about |
建立服務端腳本文件 server.js,服務端用Express作服務器
// 安裝 express 服務端代理工具也一塊兒安裝了 http-proxy-middleware
cnpm i express http-proxy-middleware --save
複製代碼
const express = require('express')
const next = require('next')
const server = express()
const port = parseInt(process.env.PORT, 10) || 3000 // 設置監聽端口
const dev = process.env.NODE_ENV !== 'production' // 判斷當前開發環境
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare()
.then(() => {
server.get('/:city', (req, res) => {
const actualPage = '/index';
const queryParams = { city: req.params.city}; // 經過 req 請求對象訪問到路徑上傳過來的參數
console.log(req.params)
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/index', (req, res) => {
const actualPage = '/index';
const queryParams = { city: req.params.city};
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/about', (req, res) => {
const actualPage = '/about';
const queryParams = { city: req.params.city};
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/posts/:id', (req, res) => {
return app.render(req, res, '/posts', { id: req.params.id })
})
server.get('*', (req, res) => {
return handle(req, res)
})
server.listen(port, (err) => {
if (err) throw err
console.log(`> Ready on http://localhost:${port}`)
})
})
複製代碼
修改package.json 文件的腳本以下:而後運行命令 npm run ssrdev 打開3000端口,至此能夠經過美化後的路由訪問到頁面了 localhost:3000/wuhan/index
localhost:3000/wuhan/about
{
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start",
"ssrdev": "node server.js", // 能夠經過nodemon 來代替node,這樣server.js 文件修改後不須要從新運行腳本
"ssrstart": "npm run build && NODE_ENV=production node server.js", // 須要先執行 npm run build
"export": "npm run build && next export"
},
"dependencies": {
"express": "^4.17.0",
"http-proxy-middleware": "^0.19.1",
"next": "^8.1.0",
"react": "^16.8.6",
"react-dom": "^16.8.6"
}
}
複製代碼
根據用戶地理位置展現對應城市站點首頁,獲取不一樣城市的數據。這裏開始就數據模擬和服務端數據獲取了。 本次項目實踐會嘗試兩種數據模擬的方式
json-server
mock.js (這種方式更簡單,後續加上)
首先安裝開源的 json-server具體使用方式參照github
cnpm install -g json-server
複製代碼
在ssr目錄下新建mock文件下,而後在mock下新建 data.json,文件數據以下
{
"index":{
"city":"wuhan",
"id":1,
"theme":"默認站點"
},
"posts": [
{ "id": 1, "title": "json-server", "author": "typicode" }
],
"comments": [
{ "id": 1, "body": "some comment", "postId": 1 }
],
"profile": { "name": "typicode" },
"seo":{
"title":"基於React.js 技術棧的服務端渲染框架Next.js 實戰記錄",
"keywords":"Winyh, Next.js, React.js, Node.js",
"description":"Next.js服務端渲染數據請求模擬頁面測試"
}
}
複製代碼
在當前目錄新建路由規則文件 routes.json 爲模擬api添加/api/前綴。文件類型以下
{
"/api/*": "/$1"
}
複製代碼
修改package.json 文件,添加數據模擬命令行腳本
{
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start",
"ssrdev": "nodemon server.js",
"ssrstart": "npm run build && NODE_ENV=production nodemon server.js",
"export": "npm run build && next export",
+ "mock": "cd ./mock && json-server --watch data.json --routes routes.json --port 4000"
},
"dependencies": {
"express": "^4.17.0",
"http-proxy-middleware": "^0.19.1",
"next": "^8.1.0",
"react": "^16.8.6",
"react-dom": "^16.8.6"
}
}
複製代碼
運行命令 npm run mock 啓動模擬數據服務器便可訪問數據
localhost:4000/api/seo
先安裝ajax請求工具
cnpm install isomorphic-unfetch --save
複製代碼
更新pages/index.js文件內容爲
import React, { Component } from 'react';
import Layout from "../components/Layouts/PcLayout"
import 'isomorphic-unfetch'
class index extends Component {
constructor(props) {
super(props);
this.state = {
city:"武漢"
};
}
static async getInitialProps({ req }) {
const res = await fetch('http://localhost:4000/api/seo')
const seo = await res.json()
return { seo }
}
componentDidMount(){
console.log(this.props)
}
render(){
const { seo } = this.props;
return (
<Layout seo={seo}>
<div>Welcome to next.js!</div>
<div>{seo.title}</div>
</Layout>
)
}
}
export default index
複製代碼
/Layouts/Pclayout.js 文件內容修改成
import HeadSeo from '../Common/HeadSeo'
export default ({ children, seo }) => (
<div id="pc-container">
<HeadSeo seo={ seo }></HeadSeo>
{ children }
</div>
)
複製代碼
/components/Common/HeadSeo.js 文件內容修改成
import Head from 'next/head'
export default ({seo}) =>
<Head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="keywords" content={seo.keywords} />
<meta name="description" content={seo.description} />
<title>{seo.title}</title>
</Head>
複製代碼
至此頁面上就能夠看到打印的數據和展現的數據了
下一步根據用戶地理位置肯定顯示的頁面城市,解決方案步驟以下【暫時只說方法,稍後完善代碼】
基本原理:根據請求頭user-agnet 判斷終端,而後渲染不一樣的組件 在static文件夾下新建 js文件夾,在 js文件夾下新建 util.js工具類模塊,代碼以下
// 根據 user-agent 請求頭判斷是否移動端
const util = {
isMobile: (req) => {
const deviceAgent = req.headers["user-agent"];
return /Android|webOS|iPhone|iPod|BlackBerry/i.test(deviceAgent)
},
};
module.exports = util
複製代碼
在pages文件夾下新建mindex.js文件,做爲移動端渲染的首頁
import React, { Component } from 'react';
import Layout from "../components/Layouts/MLayout"
import 'isomorphic-unfetch'
class index extends Component {
constructor(props) {
super(props);
this.state = {
city:"武漢"
};
}
static async getInitialProps({ req }) {
const res = await fetch('http://localhost:4000/api/seo')
const seo = await res.json()
return { seo }
}
componentDidMount(){
console.log(this.props)
}
render(){
const { seo } = this.props;
return (
<Layout seo={seo}>
<div>Welcome to next.js!</div>
<div>移動端頁面</div>
</Layout>
)
}
}
export default index
複製代碼
修改server.js文件內容以下
const express = require('express')
const next = require('next')
const server = express()
const util = require("./static/js/util");
const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare()
.then(() => {
server.get('/:city', (req, res) => {
const actualPage = util.isMobile(req) ? '/mindex' : '/index'; // 這裏是關鍵
const queryParams = { city: req.params.city};
console.log(req.params.city, actualPage)
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/index', (req, res) => {
const actualPage = '/index';
const queryParams = { city: req.params.city};
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/about', (req, res) => {
const actualPage = '/about';
const queryParams = { city: req.params.city};
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/posts/:id', (req, res) => {
return app.render(req, res, '/posts', { id: req.params.id })
})
server.get('*', (req, res) => {
return handle(req, res)
})
server.listen(port, (err) => {
if (err) throw err
console.log(`> Ready on http://localhost:${port}`)
})
})
複製代碼
用瀏覽器打開調試面板的移動端模式就能夠自動渲染移動端頁面了
其實基本已經實現了,前臺經過不一樣的頁面路由頁面參數請求後端子頁面首屏初始化數據接口,請求在服務端渲染時getInitialProps方法裏完成。 例如:/wuhan/index 能夠根據 index 做爲參數獲取後臺配置給index頁面的seo信息 /wuhan/posts 能夠根據 posts 做爲參數獲取後臺配置給posts頁面的seo信息
服務端server.js可經過以下方法實現
const dev = process.env.NODE_ENV !== 'production';
複製代碼
客戶端能夠經過配置文件next.config.js實現
/*
* @Author: winyh
* @Date: 2018-11-01 17:17:10
* @Last Modified by: winyh
* @Last Modified time: 2018-12-14 11:01:35
*/
const withPlugins = require('next-compose-plugins')
const path = require("path");
const sass = require('@zeit/next-sass')
const isDev = process.env.NODE_ENV !== 'production'
console.log({
isDev
})
// api主機
const host = isDev ? 'http://localhost:4000':'http://localhost:4001'
const {
PHASE_PRODUCTION_BUILD,
PHASE_PRODUCTION_SERVER,
PHASE_DEVELOPMENT_SERVER,
PHASE_EXPORT,
} = require('next/constants');
const nextConfiguration = {
//useFileSystemPublicRoutes: false,
//distDir: 'build',
testConfig:"www",
webpack: (config, options) => {
config.module.rules.push({
test: /\.(jpe?g|png|svg|gif|ico|webp)$/,
use: [
{
loader: "url-loader",
options: {
limit: 20000,
publicPath: `https://www.winyh.com/`,
outputPath: `/winyh/static/images/`,
name: "[name].[ext]"
}
}
]
})
return config;
},
serverRuntimeConfig: { // Will only be available on the server side
mySecret: 'secret'
},
publicRuntimeConfig: { // Will be available on both server and client
mySecret: 'client',
host: host,
akSecert:'GYxVZ027Mo0yFUahvF3XvZHZzAYog9Zo' // 百度地圖ak 密鑰
}
}
module.exports = withPlugins([
[sass, {
cssModules: false,
cssLoaderOptions: {
localIdentName: '[path]___[local]___[hash:base64:5]',
},
[PHASE_PRODUCTION_BUILD]: {
cssLoaderOptions: {
localIdentName: '[hash:base64:8]',
},
},
}]
], nextConfiguration)
複製代碼
pages/index.js經過配置文件修改api主機地址碼,代碼以下(fetch請求後面會封裝成公用方法)
import React, { Component } from 'react';
import Layout from "../components/Layouts/PcLayout"
import 'isomorphic-unfetch'
import getConfig from 'next/config' // next自帶的配置方法
const { publicRuntimeConfig } = getConfig() // 取到配置參數
class index extends Component {
constructor(props) {
super(props);
this.state = {
city:"武漢"
};
}
static async getInitialProps({ req }) {
const res = await fetch(publicRuntimeConfig.host + '/api/seo') // 從配置文件裏獲取
const seo = await res.json()
return { seo }
}
componentDidMount(){
console.log(this.props)
}
render(){
const { seo } = this.props;
return (
<Layout seo={seo}>
<div>Welcome to next.js!</div>
<div>{seo.title}</div>
</Layout>
)
}
}
export default index
複製代碼
在網頁端作微信支付或者受權時須要經過微信服務器的安全校驗,微信服務器下發一個密鑰文件*.txt,通常放在項目根目錄,須要支持訪問,例如:localhost:3000/MP_verify_HjspU6daVebgWsvauH.txt
將根目錄設置爲能夠訪問 server.use(express.static(__dirname)),這個太不安全了,根目錄全部文件都暴露了
在server.js文件里加上處理.txt文件的方法
server.get('*', (req, res) => {
const express = require('express')
const next = require('next')
const server = express()
const util = require("./static/js/util");
const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare()
.then(() => {
server.get('/:city', (req, res) => {
const txt = req.url; // 獲取請求路徑
// 這裏須要作一個請求攔截判斷
if(txt.indexOf(".txt") > 0){
res.sendFile(__dirname + `/${txt}`);
}
const actualPage = util.isMobile(req) ? '/mindex' : '/index';
const queryParams = { city: req.params.city};
console.log(req.params.city, actualPage)
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/index', (req, res) => {
const actualPage = '/index';
const queryParams = { city: req.params.city};
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/about', (req, res) => {
const actualPage = '/about';
const queryParams = { city: req.params.city};
app.render(req, res, actualPage, queryParams);
});
server.get('/:city/posts/:id', (req, res) => {
return app.render(req, res, '/posts', { id: req.params.id })
})
// server.get('*', (req, res) => {
// return handle(req, res)
// })
server.get('*', (req, res) => {
const txt = req.url; // 獲取請求路徑
if(txt.indexOf(".txt") > 0){
res.sendFile(__dirname + `/${txt}`);
} else {
return handle(req, res)
}
})
server.listen(port, (err) => {
if (err) throw err
console.log(`> Ready on http://localhost:${port}`)
})
})
複製代碼
在根目錄建立一個文件MP_verify_HjspU6daVebgWsvauH.txt測試下,瀏覽器訪問結果
在server.js 文件裏添加以下代碼,當訪問/proxy/*路由時自動匹配代理到http://api.test-proxy.com
const proxyApi = "http://api.test-proxy.com"
server.use('/proxy/*', proxy({
target: proxyApi,
changeOrigin: true
}));
複製代碼
當訪問localhost:3000/winyh路由時須要顯示 ww.redirect.com/about?type_… 頁面上的內容。 先安裝工具 cnpm i urllib --save
// 修改server.js 文件代碼
server.get('/winyh', async (req, res) => {
const agent = req.header("User-Agent");
const result = await urllib.request(
'http://ww.redirect.com/about?type_id=3',
{
method: 'GET',
headers: {
'User-Agent': agent
},
})
res.header("Content-Type", "text/html;charset=utf-8");
res.send(result.data);// 須要獲取result.data 否則顯示到前臺的數據時二進制 45 59 55
})
複製代碼
上述文章有提到,已實現
主要是編寫Dockerfile文件,本地VsCode能夠啓動容器調試,後續演示
FROM mhart/alpine-node
WORKDIR /app
COPY . .
RUN yarn install
RUN yarn build
EXPOSE 80
CMD ["node", "server.js"]
複製代碼
最後總結: