Skip to main content

记一次Next.js搭建之旅

· 11 min read

这段时间工作要求将之前的 vue 的 H5 活动页面转移到 Next.js 下面,经过一段时间的研究,总结出了下面的内容。

什么是 Next.js

基于React的 SSR(服务端渲染框架)

SSR & CSR

参考链接:https://medium.com/walmartglobaltech/the-benefits-of-server-side-rendering-over-client-side-rendering-5d07ff2cefe8

The main difference is that for SSR your server’s response to the browser is the HTML of your page that is ready to be rendered, while for CSR the browser gets a pretty empty document with links to your javascript. That means your browser will start rendering the HTML from your server without having to wait for all the JavaScript to be downloaded and executed. In both cases, React will need to be downloaded and go through the same process of building a virtual dom and attaching events to make the page interactive — but for SSR, the user can start viewing the page while all of that is happening. For the CSR world, you need to wait for all of the above to happen and then have the virtual dom moved to the browser dom for the page to be viewable.

Next.js 的优点

  • 更好的 SEO
  • 更快的首屏渲染速度

Next.js 基础(与 React 开发的不同之处)

https://www.nextjs.cn/learn/basics/create-nextjs-app?utm_source=next-site&utm_medium=nav-cta&utm_campaign=next-website

路由映射

在 Next.js 中,一个 page(页面) 就是一个从 .jsjsx.ts.tsx 文件导出(export)的 React 组件 ,这些文件存放在 pages 目录下。每个 page(页面)都使用其文件名作为路由(route)。

pages/about.js/jsx/ts/tsx → /about

pages/dashboard/settings/username.js/dashboard/settings/username

使用与react-router类似,包括编程式跳转router.push以及组件式跳转<Link href="/about"><a>click me</a></Link>

import { useRouter } from 'next/router';

const router = useRouter();
router.push({
pathname: '/activities/experience-lesson/course-info',
query: { ...queryData, isFree: 0 }
});
router.push('/about');

渲染方式

预渲染

  • 静态生成(Static Generation)(HTML 重用、build 生成)
  • 服务器端渲染(Server-side Rendering)(每次请求生成的 HTML 不同、用户请求时生成)

相关 API

  • 静态生成

    • getStaticProps(context)
    • getStaticPaths(context)
  • 服务器渲染

    • getServerSideProps(context)
  • 客户端获取数据

    • SWR(官方推荐)

注意:在开发环境中getStaticPropsgetStaticPaths每次请求都会被调用

使用,在页面文件中导出

function Page({ data }) {
// Render data...
}

// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`);
const data = await res.json();

// Pass data to the page via props
return { props: { data } };
}

export default Page;

项目结构及工程化

配置 Eslint+Prettier

https://github.com/paulolramos/eslint-prettier-airbnb-react

https://dev.to/karlhadwen/setup-eslint-prettier-airbnb-style-guide-in-under-2-minutes-a27

https://dev.to/bybruno/configuring-absolute-paths-in-react-for-web-without-ejecting-en-us-52h6

解决eslint无法识别动态引入语法import():

相关 issue

// eslint 配置
parserOptions: {
ecmaVersion: 2020, // Use the latest ecmascript standard
sourceType: 'module', // Allows using import/export statements
ecmaFeatures: {
jsx: true // Enable JSX since we're using React
}
},

考虑到团队协作,在根目录下创建.vscode文件夹并在里面创建setting.json文件以达到保存自动修复的效果,保证了团队代码的统一性。

{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

后期经过一段时间发现有些团队成员的编辑器设置了实时保存功能,于是就直接跳过了这一步,这里便可以用husky(用来给 git 添加 hook)搭配lint-staged在 commit 代码前来自动做这一件事情。

yarn add husky lint-staged prettier --dev

编写package.json

"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js, jsx}": [
"npm run lint",
"git add"
]
}

配置 alias

next.config.js配置

/* eslint-disable no-param-reassign */
const path = require('path');

module.exports = {
webpack: config => {
// Note: we provide webpack above so you should not `require` it
// Perform customizations to webpack config
config.resolve.alias['@'] = path.resolve(__dirname, './src');
// Important: return the modified config
return config;
}
};

eslint 无法识别 alias,需要在根目录下创建文件jsconfig.json并在.eslintrc.js 配置settings

{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
},
"exclude": ["node_modules", "**/node_modules/*"]
}

封装 axios 在每次请求时显示spin组件

这里要注意一点,由于服务端不存在document,所以要判断一下当前所处的环境再去执行操作。

详细代码
import axios from 'axios';
import ReactDOM from 'react-dom';
import Spin from '../components/Spin/Spin';

const Axios = axios.create({
timeout: 20000
});

const csr = process.browser;

// 当前正在请求的数量
let requestCount = 0;

function showLoading() {
if (requestCount === 0) {
var dom = document.createElement('div');
dom.setAttribute('id', 'loading');
document.body.appendChild(dom);
ReactDOM.render(<Spin />, dom);
}
requestCount++;
console.log('showLoading', requestCount);
}

function hideLoading() {
requestCount--;
if (requestCount === 0) {
document.body.removeChild(document.getElementById('loading'));
}
console.log('hideLoading', requestCount);
}

Axios.interceptors.request.use(
config => {
csr && showLoading();
return config;
},
err => {
csr && hideLoading();
return Promise.reject(err);
}
);

Axios.interceptors.response.use(
res => {
csr && hideLoading();
return res;
},
err => {
csr && hideLoading();
return Promise.reject(err);
}
);

export default Axios;

自定义 input hook

使用后可以免去给每个表单组件设置onChange

import { useState } from 'react';

// 自定义input hook
// 参考资料:https://rangle.io/blog/simplifying-controlled-inputs-with-hooks/
export function useInput(initialValue) {
const [value, setValue] = useState(initialValue);

return {
value,
setValue,
reset: () => setValue(''),
bind: {
value,
onChange: e => {
setValue(e.target.value);
}
}
};
}

使用:

// 没使用前
const [phone, setPhone] = useState('');

<input
name="phone"
type="number"
placeholder="请输入您的手机号码(必填)"
className={`${styles['cell-content']} ${styles['cell-content-right']}`}
value={phone}
onChange={() => setPhone(e.target.value)}
/>;

// 使用后
const { value: phone, bind: bindPhone } = useInput('');

<input
name="phone"
type="number"
placeholder="请输入您的手机号码(必填)"
className={`${styles['cell-content']} ${styles['cell-content-right']}`}
{...bindPhone}
/>;

封装 Dialog

import { createPortal } from 'react-dom';
import styles from './Modal.module.css';

export default function Modal({ content, show, onOk }) {
const modal = show && (
<div className={styles['overlay']}>
<div className={styles['modal']}>
{/* 防止冒泡关闭窗口 */}
<div className={styles['wrapper']} onClick={e => e.stopPropagation()}>
<div className={styles['content']}>{content}</div>
<div className={styles['readed_btn']} onClick={() => onOk()}>
好 的
</div>
</div>
</div>
</div>
);

const ProtalContent = () => {
// 用来处理服务端不存在document的问题
try {
// 将modal挂在到body上
return document && createPortal(modal, document.body);
} catch (error) {
return null;
}
};

// 动态引入组件
// import dynamic from 'next/dynamic';
// const Modal = dynamic(() => import('./components/Modal/Modal'), { ssr: false });

return (
<>
<ProtalContent />
</>
);
}

移动端适配

使用插件postcss-px-to-viewport

在根目录下新建文件postcss.config.js

module.exports = {
plugins: {
'postcss-px-to-viewport': {
// 视窗的宽度,对应的是我们设计稿的宽度,我们公司用的是375
viewportWidth: 375,
// 视窗的高度,根据750设备的宽度来指定,一般指定1334,也可以不配置
// viewportHeight: 1334,
// 指定`px`转换为视窗单位值的小数位数
unitPrecision: 3,
// 指定需要转换成的视窗单位,建议使用vw
viewportUnit: 'vw',
// 指定不转换为视窗单位的类,可以自定义,可以无限添加,建议定义一至两个通用的类名
selectorBlackList: ['.ignore'],
// 小于或等于`1px`不转换为视窗单位,你也可以设置为你想要的值
minPixelValue: 1,
// 允许在媒体查询中转换`px`
mediaQuery: false
// exclude: undefined
}
}
};

使用 Docker+coding 实现自动化部署

docker 常用命令

dockerfile

# node版本号
FROM node:12-alpine

# docker build时传进来的值 docker image build -t <name> --build-arg API_ENV=development .
ARG API_ENV

RUN echo ${API_ENV}

ENV NEXT_PUBLIC_API_ENV=${API_ENV}

# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

# Install app dependencies
COPY package*.json /usr/src/app/
RUN npm install

# Bundle app source
COPY . /usr/src/app

RUN npm run build
EXPOSE 3000

CMD [ "npm", "run", "start" ]

在 coding 上设置代码 push 触发规则,触发生成制品库。

使用 redux

https://github.com/vercel/next.js/tree/canary/examples/with-redux

https://github.com/vercel/next.js/tree/canary/examples/with-redux-thunk

里面有使用到一个 js 新特性Nullish coalescing operator

Next.js 踩坑

环境变量配置

环境变量在客户端无法获取,背景:由于我在项目中需要根据环境变量来使用不同环境的 API 域名。

解决方案:官方提供了以NEXT_PUBLIC_开头的环境变量名,这样就可以在客户端和服务端都访问得到环境变量了。

判断环境

process.browser === true ? 'client' : 'server'