手动搭建一个SSR mvp项目

149 阅读12分钟

大家好,我是吃外卖也分期的李一疯,这篇文章主要是实现SSR mvp项目,项目选用React、Redux、Webpack,仓库地址:liyunfu1998/react-ssr-template (github.com)

手动搭一个SSR项目

服务器端渲染的优点

  • 提高渲染速度:它在服务器上进行预渲染并减少加载时间
  • 更好的搜索引擎优化:搜索引擎变得更好,因为它们可以轻松地对SSR应用程序中的内容进行排名和索引
  • 增强的用户体验:用户可以更快地获得内容并增强性能
  • 可及性:即使禁止了javascript,用户也可以使用内容
  • 在社交媒体上分享:在社交媒体平台上共享URL时,它会生成准确的预览

一个完整的SSR项目需要实现什么

  • 设置服务器:选择服务器端框架(如Express.js)来处理SSR请求
  • React水合作用:在服务器上呈现HTML后,javascript会使其具有交互性
  • 获取数据:异步数据提取或在渲染前获取内容等技术getInitialProps
  • 处理路由:配置服务器路由以处理不同的URL和路由

优化SSR应用中的性能

  • 缓存:缓存可以缩短渲染时间,因为缓存会渲染页面并缩短加载时间
  • ISR:ISR是增量静态再生,它使用动态数据生成和缓存页面
  • 客户端导航:初始加载后,客户端导航可改善用户体验

实现基于React的SSR

步骤一:写一个最简SSR

目前我们的服务端框架选择的是Express.js,打包工具选择的是Webpack,现在就先将依赖安装完成吧

pnpm i express
pnpm i @babel/preset-env babel-loader nodemon ts-loader webpack webpack-cli webpack-merge -D

先在根目录下新建src/server/index.js

mkdir -p src/server
touch src/server/index.js

编辑index.js

import express from "express";
import childProcess from "child_process";

const app = express();

app.get("*", (req, res) => {
  res.send(`
    <html>
      <body>
        <div>hello ssr</div>
      </body>
    </html>
  `);
});

app.listen(3000, () => {
  console.log("Server is listening on port 3000");
});

childProcess.exec("start http://localhost:3000/");

现在来配置webpack.base.js,主要是配置针对js、ts的通用loader方案

const path = require("path");

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: "babel-loader",
        exclude: /node_modules/,
        options: {
          presets: ["@babel/preset-env"],
        },
      },
      {
        test: /.(ts|tsx)$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
    alias: {
      "@": path.resolve(process.cwd(), "src"),
    },
  },
};

继续配置webpack.server.js,配置入口文件,及打包结果

const { merge } = require("webpack-merge");
const baseConfig = require("./webpack.base");

module.exports = merge(baseConfig, {
  mode: "development",
  entry: "./src/server/index.js",
  target: "node",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "server_build"),
  },
});

设置package.json的脚本命令

  "scripts": {
    "start": "npx nodemon server_build/bundle.js",
    "build:server": "npx webpack build --config ./webpack.server.js --watch"
  },

运行npm run build:server后可以看到成功打包后的文件server_build/bundle.js

现在我们运行npm run start 打开浏览器页面刷新可以在Network中看到请求的数据 ssr初版

目前我们看到的就是SSR初版,即拿回来的页面直接就是带有数据的,而不是客户端渲染那种只有一个div id='root'类型,当然现在这个版本是完全不能用的,先pr一波,顺便Tips:项目中还配置了tsconfig.json需要的前往仓库获取 SSR初版实现

步骤二:渲染静态DOM

目前我们实现的只是一个模板字符串渲染,完全不可用,现在需要实现React组件渲染为字符串输出,下面来实现它吧

我们需要先安装React ReactDOM的依赖

pnpm i react react-dom
pnpm i @types/react @types/react-dom @types/node @babel/preset-react  -D

现在我们来写一个React的Home组件

mkdir -p src/pages/Home
touche src/pages/Home/index.tsx
import { FC } from "react";

const Home:FC=()=>{
  return (
    <div>
      <h1>hello-ssr</h1>
      <button onClick={():void=>{alert('hello-ssr')}}>alter</button>
    </div>
  )
}

export default Home

修改tsconfig.json配置,noEmitfalse

{
  "compilerOptions": {
    "module": "CommonJS",
    "types": ["node"], // 声明类型,使得ts-node支持对tsx的编译
    "jsx": "react-jsx", // 全局导入, 不再需要每个文件定义react
    "target": "es6",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": false,
    "baseUrl": "./",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"]
}

修改webpack.base.js,对于js做loader的时候,还需要添加react预设,presets: ["@babel/preset-env", "@babel/preset-react"],

然后我们在src/server/index.js中引入Home组件,转换成HTMl

import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import React from "react";
import Home from "@/pages/Home";

const app = express();
const content = renderToString(<Home />); // 编译需要渲染的jsx,转换成对应的html

app.get("*", (req, res) => {
  const htmlDOM = `
    <html>
      <body>
        <div id="root">${content}</div>
      </body>
    </html>
  `;
  res.send(htmlDOM);
});

app.listen(3000, () => {
  console.log("Server is listening on port 3000");
});

childProcess.exec("start http://localhost:3000/");

现在打包并运行效果如下: SSR渲染静态DOM

代码如下:SSR渲染静态DOM 为什么我们点击alert按钮没效果呢,这就涉及到同构了,即同一套React代码在服务器端渲染一遍,然后在客户端再执行一遍,服务端负责静态dom的拼接,而客户端负责事件的绑定,不仅是模板页面渲染,后面的路由,数据的请求都涉及到同构,

步骤三:实现客户端部分

新增一个src/client/index.tsx,前面我们的src/server/index.js也可以改变为tsx结尾

// src/client/index.tsx

import { hydrateRoot } from "react-dom/client";
import Home from "@/pages/Home";

hydrateRoot(document.getElementById("root") as Document | Element, <Home />);

hydrateRoot需要指定一个绑定的真实dom,我们这里给他设置为root

再进行客户端的webpack.client.js配置

const path = require("path");
const { merge } = require("webpack-merge");
const baseConfig = require("./webpack.base");

module.exports = merge(baseConfig, {
  mode: "development",
  entry: "./src/client/index.tsx",
  output: {
    filename: "index.js",
    path: path.resolve(process.cwd(), "client_build"),
  },
});

主要逻辑就是打包,输出到client_build目录下

然后我们在src/server/index.tsx中引入

首先将client_build目录设置为静态资源目录

app.use(express.static(path.resolve(process.cwd(), "client_build")));

然后在模板字符串里面引入脚本

<script src="/index.js"></script>

完整src/server/index.js代码如下:

import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import React from "react";
import Home from "@/pages/Home";
import path from "path";

const app = express();
const content = renderToString(<Home />); // 编译需要渲染的jsx,转换成对应的html

app.use(express.static(path.resolve(process.cwd(), "client_build")));

app.get("*", (req, res) => {
  const htmlDOM = `
    <html>
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `;
  res.send(htmlDOM);
});

app.listen(3000, () => {
  console.log("Server is listening on port 3000");
});

childProcess.exec("start http://localhost:3000/");

最后添加一个新的启动脚本命令

"build:client": "npx webpack build --config ./webpack.client.js --watch",

运行情况如下: SSR事件绑定

可以看到截图中,先请求的html页面,并且紧接着获取了index.js脚本,点击alert有效果了

代码如下:SSR事件绑定

步骤四:实现路由的匹配

我们的客户端和服务端的返回需要保持一致,这样才能符合同构,不然会有客户端的报错,页面也没有办法正常匹配,所以需要同时为客户端和服务端的入口都加上对应的路由匹配

首先来安装路由相关依赖:

npm i react-router-dom 

现在我们多增加一个demo页面:

// ./src/pages/Demo/index.tsx
import { FC } from "react";

const Demo: FC = (data) => {
  return (
    <div>这是一个demo页面</div>
  );
};

export default Demo;

再来创建路由配置文件

// ./src/router.tsx
import Home from "@/pages/Home";
import Demo from "@/pages/Demo";

interface IRouter {
  path: string;
  element: JSX.Element;
}

const router: Array<IRouter> = [
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/demo",
    element: <Demo />,
  },
];

export default router;

接下来改造客户端,使用到的是BroserRouter

// ./src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import router from "@/router";

const Client = (): JSX.Element => {
  return (
    <BrowserRouter>
      <Routes>
        {router?.map((item, index) => {
          return <Route {...item} key={index} />;
        })}
      </Routes>
    </BrowserRouter>
  );
};

hydrateRoot(document.getElementById("root") as Document | Element, <Client />);

之后也为服务端添加路由

// ./src/server/index.tsx
import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import path from "path";
import router from "@/router";
import { Route, Routes } from "react-router-dom";
import { StaticRouter } from "react-router-dom/server";

const app = express();

app.use(express.static(path.resolve(process.cwd(), "client_build")));

app.get("*", (req, res) => {
  const content = renderToString(
    <StaticRouter location={req.path}>
      <Routes>
        {router?.map((item, index) => {
          return <Route {...item} key={index} />;
        })}
      </Routes>
    </StaticRouter>
  );

  res.send(`
    <html
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000, () => {
  console.log("ssr-server listen on 3000");
});

childProcess.exec("start http://127.0.0.1:3000");

这里用到的是StaticRouter,拷贝了一段AI回答:

StaticRouter是React Router v4中的一个组件,它用于在服务器端渲染(SSR)时使用。它允许你在服务器上渲染一个静态路由树,然后在客户端上使用一个完全相同的路由树。

StaticRouter的主要用途是在服务器端渲染(SSR)应用程序时使用。在SSR中,服务器会生成HTML,并将其发送到客户端。这样可以提高应用程序的性能和搜索引擎优化(SEO)。

StaticRouter接收一个props对象,其中包含location和context两个属性。location是一个字符串,表示当前的路由路径。context是一个对象,用于存储路由信息。

在服务器端,你可以使用StaticRouter来生成HTML,并将其发送到客户端。在客户端,你可以使用BrowserRouter或HashRouter来处理路由。

总的来说,StaticRouter在服务器端渲染应用程序时非常有用,可以提高应用程序的性能和SEO。

TIPS: 因为存在客户端路由和服务端路由,所以服务端渲染通过不同的方式跳转也会采用不同的渲染方式,当使用React内置的路由跳转的时候,会进行客户端路由的跳转采用客户端渲染; 而通过a标签,或者原生方式打开一个新页面的时候,才会进行服务端路由的跳转,使用服务端渲染

所以如果仅做部分页面的SSR是不是就可以在进入特定页面的时候用a标签呢

步骤五: 实现Header标签的修改

对于SEO来说,相关的页面中加入meta等关键字是必要的,所以我们如何才能在服务端渲染的时候修改header呢,可以用到react-helmet来实现

安装:

npm install react-helmet --save
npm install @types/react-helmet --save-dev

改造客户端代码以Home页为例

// ./src/pages/Home/index.tsx
import { useNavigate } from "react-router-dom";
import { Fragment } from "react";
import { Helmet } from "react-helmet";

const Home = () => {
  const navigate = useNavigate();

  return (
    <Fragment>
      <Helmet>
        <title>简易的服务器端渲染 - HOME</title>
        <meta name="description" content="服务器端渲染"></meta>
      </Helmet>
      <div>
        <h1>hello-ssr</h1>
        <button
          onClick={(): void => {
            alert("hello-ssr");
          }}
        >
          alert
        </button>
        <a href="http://127.0.0.1:3000/demo">链接跳转</a>
        <span
          onClick={(): void => {
            navigate("/demo");
          }}
        >
          路由跳转
        </span>
      </div>
    </Fragment>
  );
};

export default Home;

相应的需要保证服务端的返回也是相通的header,可以用Helmet.renderStatic()获取渲染的header注入返回HTML字符串

import express from "express";
import childProcess from "child_process";
import { renderToString } from "react-dom/server";
import path from "path";
import router from "@/router";
import { Route, Routes } from "react-router-dom";
import { StaticRouter } from "react-router-dom/server";
import { Helmet } from "react-helmet";

const app = express();

app.use(express.static(path.resolve(process.cwd(), "client_build")));

app.get("*", (req, res) => {
  const content = renderToString(
    <StaticRouter location={req.path}>
      <Routes>
        {router?.map((item, index) => {
          return <Route {...item} key={index} />;
        })}
      </Routes>
    </StaticRouter>
  );

  const helmet = Helmet.renderStatic();

  res.send(`
    <html
      <head>
        ${helmet.title.toString()}
        ${helmet.meta.toString()}
      </head>
      <body>
        <div id="root">${content}</div>
        <script src="/index.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000, () => {
  console.log("ssr-server listen on 3000");
});

childProcess.exec("start http://127.0.0.1:3000");

现在就可以看到服务端渲染返回的页面会有相应的head配置

image.png

步骤六:实现数据注入

首先我们来回忆一下,静态页面的思路,是在服务器端拼凑好HTML并返回,所以请求的话,咱们应该也是获取到每个模板页面初始化的请求,并在服务器端请求好,进行HTML拼凑,在这之前我们需要建立一个全局的store,使得服务器端请求的数据可以提供到模板页面来进行操作

全局store的建立

我们选用redux来进行状态管理

安装

npm install @reduxjs/toolkit redux-thunk react-redux --save

首先来定义一个demoSlice

// ./src/stores/demoSlice.ts
import {createSlice, createAsyncThunk} from '@reduxjs/toolkit';
import axios from 'axios'

export const addNewPost = createAsyncThunk(
  "posts/addNewPost",
  async () => {
    const response = await axios.get(
      "https://jsonplaceholder.typicode.com/posts",
    );
    console.log('response',response)
    return response.data?.[0]?.title;
  }
);

const demoStore = createSlice({
  name: "demo",
  initialState: {
    content: "默认数据",
  },
  reducers: {},
  extraReducers(builder) {
    builder
      .addCase(addNewPost.pending, (state, action) => {
        state.content = "loading";
      })
      .addCase(addNewPost.fulfilled, (state, action) => {
        state.content = action.payload;
      })
      .addCase(addNewPost.rejected, (state, action) => {
        state.content= "请求失败";
      });
  },
});

export default demoStore.reducer

主要是利用createAsyncThunk来做异步,都是redux基础教程中的可先去学习一下:Redux 基础教程,第三节:数据流基础 | Redux 中文官网

然后分别创建clientStoreserverStore 这里其实可以直接用一个,反正两者要实现同构,没啥区别

// ./src/stores/clientStore
import {configureStore} from '@reduxjs/toolkit'
import demoSlice from './demoSlice'

const store = configureStore({
  reducer: {
    demo: demoSlice
  }
})

export default store
// ./src/stores/serverStore.ts
import { configureStore } from "@reduxjs/toolkit";
import demoSlice from "./demoSlice";

const store = configureStore({
  reducer: {
    demo: demoSlice,
  },
});

export default store;

然后分别在clientserver中引入

// ./src/client/index.tsx
import { hydrateRoot } from "react-dom/client";
import router from '@/router'
import { BrowserRouter, Route, Routes } from "react-router-dom";
import {Provider} from 'react-redux'
import store from '@/stores/clientStore'

const Client = ():JSX.Element =>{
  return (
    <Provider store={store}>
    <BrowserRouter>
      <Routes>
        {router?.map((item,index)=>{
          return <Route key={index} {...item} />
        })}
      </Routes>
    </BrowserRouter>
    </Provider>
  )
}

hydrateRoot(document.getElementById("root") as Document | Element, <Client />);
// ./src/server/index.tsx
import express from 'express'
import childProcess from 'child_process'
import {renderToString} from 'react-dom/server'
import path from 'path'
import router from '@/router'
import {Route, Routes} from 'react-router-dom'
import {StaticRouter} from 'react-router-dom/server'
import {Helmet} from 'react-helmet'
import {Provider} from 'react-redux'
import store from '@/stores/serverStore'

const app = express()

app.use(express.static(path.resolve(process.cwd(),'client_build')))

app.get('*', (req,res)=>{
  const content = renderToString(
    <Provider store={store}>
    <StaticRouter location={req.path}>
      <Routes>
        {
          router?.map((item,index)=>{
            return <Route key={index} {...item}/>
          })
        }
      </Routes>
    </StaticRouter>
    </Provider>
  )

  const helmet = Helmet.renderStatic()

  res.send(`
      <html>
        <head>
          ${helmet.title.toString()}
          ${helmet.meta.toString()}
        </head>
        <body>
          <div id="root">${content}</div>
          <script src="client.js"></script>
        </body>
      </html>
  `)
})

app.listen(3000,()=>{
  console.log('ssr-server listen on 3000')
})

childProcess.exec('start http://localhost:3000')

目前我们就可以直接在页面文件中使用了,以Demo页面为例:

// ./src/pages/Demo/index.tsx
import {FC} from 'react'
import {useSelector, useDispatch} from 'react-redux'
import { addNewPost } from "@/stores/demoSlice";
const Demo:FC=()=>{
  const dispatch = useDispatch()
  const demo = useSelector((state:any)=>state.demo.content)

  
  const onFetchClick =async ()=>{
    try{
      await dispatch(addNewPost() as any).unwrap();
    }catch(e){
      console.log('请求失败',e)
    }
  }

  return (
    <div>
      <h1>数据:{demo}</h1>
      <button onClick={()=>onFetchClick()}>获取数据</button>
    </div>
  )
}

export default Demo

上面代码主要是实现,初始进入页面显示初始数据,和获取数据按钮,点击按钮,请求接口拿到数据。

效果如下:

image.png

现在全局store建立了,我们要怎么实现在进入页面的时候就拿到数据拼接好的html呢,这就要思考一下了?

首先我们肯定得先在服务器端拿到所有需要请求的函数,怎么透传过去呢?我们应该可以使用路由,因为客户端和服务端咱们都有配置路由,如果加一个参数通过路由把参数透传,然后在服务器端遍历,最后把结果对应分发就可以了

TIPS:服务器端下的axios请求应该是包含域名的绝对路径,而不是使用相对路径,因为服务端拿不到域名

利用路由进行服务器端数据预请求,拼凑HTML

如上面所说,我们需要对router进行改造,添加一个loadData的字段:这个方法就是初始化触发的函数

// ./src/router.tsx
import Home from './pages/Home'
import Demo from './pages/Demo'
import { addNewPost } from './stores/demoSlice';
interface IRouter {
  path: string;
  element: JSX.Element;
  loadData?: (store: any) => any;
}

const router:Array<IRouter> =[
  {
    path: '/home',
    element: <Home />
  },
  {
    path: '/demo',
    element: <Demo />,
    loadData: (store)=>{return store.dispatch(addNewPost() as any).unwrap()}
  }
]

export default router

然后对服务端渲染进行改造,主要是拿到路由的loadData这个函数,然后根据router跳转到哪儿匹配出需要对应调用的请求函数,使用Promise.all包裹,多个promise函数,达到请求完成再拼接返回html

// ./src/server/index.tsx

import express from 'express'
import childProcess from 'child_process'
import {renderToString} from 'react-dom/server'
import path from 'path'
import router from '@/router'
import {Route, RouteObject, Routes, matchRoutes} from 'react-router-dom'
import {StaticRouter} from 'react-router-dom/server'
import {Helmet} from 'react-helmet'
import {Provider} from 'react-redux'
import store from '@/stores/serverStore'

const app = express()

app.use(express.static(path.resolve(process.cwd(),'client_build')))

app.get('*', (req,res)=>{
  const routeMap = new Map<string, ()=>Promise<any>>() // path - loadData的map
  // 对router遍历,拿到loadData的方法,然后执行
  router?.forEach(item=>{
    if(item.path && item.loadData){
      routeMap.set(item.path,item.loadData(store))
    }
  })

  // 匹配当前路由的routes
  const matchedRoutes = matchRoutes(router as RouteObject[], req.path)

  const promises:Array<()=> Promise<any>> = []

  matchedRoutes?.forEach(item=>{
    if(routeMap.has(item.pathname)){
      promises.push(routeMap.get(item.pathname) as ()=>Promise<any>)
    }
  })

  Promise.all(promises).then(()=>{
     const content = renderToString(
       <Provider store={store}>
         <StaticRouter location={req.path}>
           <Routes>
             {router?.map((item, index) => {
               return <Route key={index} {...item} />;
             })}
           </Routes>
         </StaticRouter>
       </Provider>
     );

     const helmet = Helmet.renderStatic();

     res.send(`
      <html>
        <head>
          ${helmet.title.toString()}
          ${helmet.meta.toString()}
        </head>
        <body>
          <div id="root">${content}</div>
          <script>
             window.context={
                state: ${JSON.stringify(store.getState())}
             }
          </script>
          <script src="client.js"></script>
        </body>
      </html>
  `);
  })
 

  
})

app.listen(3000,()=>{
  console.log('ssr-server listen on 3000')
})

childProcess.exec('start http://localhost:3000')

效果如下一进入页面就能看到服务端渲染返回的页面是已经拿到数据的了

image.png

但是貌似出现了新的问题,为什么服务端渲染已经返回了预期页面,但是闪一下,又变回客户端渲染的非预期页面了

因为客户端和服务器端的store是不同步的,服务器端请求完成填充store后,客户端的JS又执行了一遍store,取了默认值,所以导致数据不同步,要解决这个问题,就需要使用脱水和注水的方式

脱水和注水

我们先搞清楚一点,我们是先拿到服务端渲染的页面,再请求的客户端js脚本注入,所以客户端的话我们可以脱水即移除其数据层的部分,仅仅保留dom的部分,然后再服务器端拿到store以后,对数据进行注入,放到window.context中,使得客户端的数据与服务端请求的数据保持一致,就可以解决掉不同步的问题了

注水

只需要对./src/server/index.tsx的模板字符串进行改造

image.png

脱水

只需要对./src/stores/demoSlice进行脱水如下:

image.png

因为SSR的话是没有window的,所以我们可以通过判断window不为undefined的时候,将初始数据使用服务端渲染注入到window中的

到此刚进入页面,服务端和客户端渲染呈现的页面一致

image.png

可参考仓库:liyunfu1998/react-ssr-template (github.com)

针对大图低网速加载场景的首屏优化方案

方案一

针对该场景,我们首先需要知道的是什么时候是低网速的,目前浏览器提供了navigator.connection.effectiveType来获取当前的流量状态,这样我们就可以根据不同的网速场景,进行图片清晰度的选择,API返回的结果如下:

  1. slow-2g
  2. 2g
  3. 3g
  4. 4g

在低网速下优先加载0.5X或1X的图片,同时也加载2X的大图,通过隐藏DOM的方式隐性加载,然后监听2X资源的onload事件,在资源加载完成时,进行类的替换

但是当前为实验性属性,兼容性不太好,如果用户非safarifirefox用户,那可以尽情使用

方案二

针对不同的像素场景也需要使用响应式图片,即pc可能更清晰,h5可以低画质一些,浏览器给我们提供了img srcsetpicture,可以根据不同的像素场景自动选取不同的元素进行适配

//img srcset
<img srcset="abc-480w.jpg 480, abc-800w.jpg 800w" sizes="{max-width: 600px} 480px, 800px" src="abc-800w.jpg">

// picture
<picture>
    <source srcset="/media/abc-240-200.jpg" media="(min-width: 800px)">
    <img src="/media/abc-298-332.jpg" />
</picture>

方案三

使用webp格式图片,基本上可以比原有jpg降低40%体积,因为一些浏览器不支持webp,需要进行兼容性判断:

export const getIsSupportWebp = (context: AppContext) => {
    const {headers = {} } = context.ctx.req || {}
    return headers.accept?.include('image/webp')
}

为什么在极快速度下webp反倒相对更慢

因为webp的低体积并不是毫无代价的,他在压缩过程中进行了分块帧内预测量化等操作,这些操作减少了webp的体积,但是作为交换的事,需要更长的解析时间,不过这不是受网速限制,是受浏览器限制,但是几毫秒差距的用户体验并不会多大影响,反倒是随着网速变差,体积优化的效果很显著

IOS设备兼容

300 ms delay

在平时的开发事件的触发大部分都是立刻响应的,但是IOS设备,会有300ms的延迟,因为IOS浏览器双击屏幕可以进行缩放,当用户点击链接后,浏览器没办法判定用户是想双击缩放,还是进行点击事件触发,所以IOS Safari会统一等待300ms,来判断用户是否会再次点击屏幕

解决方案

  1. Meta禁止缩放
  2. 更改视口尺寸
  3. Touch-action

Meta禁止缩放 因为300ms延迟的初衷是为了解决点击和缩放没办法区分的问题,针对不需要缩放的页面,我们可以通过禁止缩放来解决,大部分页面都是可以避免缩放,通过某些交互样式来兼容缩放的

// 在head中加着两行即可
<meta name="viewport" content="user-scalable=no" >
<meta name="viewport" content="initial-scale=1,maximum-scale=1">

更改视口尺寸 chrome对于width=device-width或者比viewport更小的页面禁用双击,但是IOS的Safari不支持,这个属性可以让浏览器自适应设备的屏幕尺寸

<meta name="viewport" content="width=device-width">

Touch-action

这是草案

使用css属性,touch-action: none可以移除目标元素的300ms delay

橡皮筋问题

IOS上,当页面滚动到顶部或底部仍可向下或向上拖拽,并伴随一个弹性的效果,该效果被称为'rubber band'

解决方案

我们给body甚至overflow:hidden,然后对根结点设置100页宽的高度,将外部body的滚动移动到页面内,这样外界的滚动相关的问题都会解决,因为我们页面采用的实际是内部滚动

.forbidScroll {
	height: 100vh;
	overflow: hidden
}
body {
	overflow: hidden
}
#__next: {
  height: 100vh;
  overflow: auto
}

如何做SEO优化

技术优化

语义化标签

  1. H1是一个页面中权重最高,关键词优先级最高的文案,一个页面只能使用一个
  2. 页面中我们通常只使用H1~H3,剩下的标题优先级太低,部分搜索引擎不会进行识别
  3. H标签通常不适用在文字logo、标题栏、侧边栏等每页固定存在的部分,因为这部分不属于这一页的重点,既不是与众不同的区域

Meta

  1. Title
  2. Description:页面描述,SEO的关键;PC端不要超过155个字符,移动端不要超过120个字符,如果过长,页面描述会被截断,反而影响最终的SEO
  3. Keywords:关键词,每个页面通常设置比较重要的3、4个关键字;关键词由高到低用逗号分隔,每个关键词都要是独特的,不要每个关键词意思差不多
  4. robots:是否开启搜索引擎抓去,noindex对应是否开启抓去,nofollow对应不追踪网页的链接,需要开启
  5. Applicable-device:告诉Google,你这个站点适配了那些设备,不加就是默认PC端,将会影响移动端搜索你站点
  6. Format-detection:在默认状态下,网页的数字会被认为是电话号码,点击数字会被当作电话号码,所以需要禁用
<meta name="format-detection" content="telephone=no" />

Sitemap

欢迎使用 Google Search Console 在谷歌添加