第一次在掘金上发布文章,本着学习的态度,将本身运用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 否则会报错
复制代码
这里跟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"]
复制代码
最后总结: