没想到你是这样的SSR

1,720 阅读4分钟

如今前端,React、Angular、Vue三足鼎立,再加上ES6的发布,大大改变了前端的开发方式,模块化、组件化的普及也给开发带来了极大的便利,它们的一些衍生技术,比如react-native, weex,也赋予了前端开发体验度良好的APP的能力。

提到三大框架,不得不提的就是SPA(Single Page Application), 而SPA最大的问题就在于首屏渲染和SEO方面,因此为了补足SPA的这两个致命缺点,就要用到服务端渲染。那么服务端和客户端渲染有什么区别呢?大致总结了一下:

服务端渲染 客户端渲染
优点 1、首屏渲染快
2、利于SEO
3、缓存数据
1、前后端分离
2、局部刷新,用户体验好
3、节约服务器资源
缺点 1、用户体验差
2、不易于维护,客户端要改,服务端有时也要更改
3、占用服务器资源
1、SEO不友好
2、首屏渲染慢

针对SPA的SSR,网上有很多与该话题相关的文章,也有一些相关的框架,如next.js,nuxt.js等。但今天我们所讲的ssr和这些没什么关系,而是利用另外一个骚操作来完成。

是什么呢,就是puppeteer,关于puppeteer的相关介绍,我在之前的文章有介绍过 详见:

puppeteer在开发过程中的实践

puppeteer初探

实现思路

puppeteer-ssr

首先需要把你的react/vue项目打包到指定的文件目录(静态资源),如 "dist"目录 然后执行puppeteer-ssr后会进行如下操作

  • 会在项目下启动一个node服务,服务地址为: http://localhost:8888
  • 运行puppeteer,启动一个browser实例,根据配置的路由,如['/','/login'],然后跳转路由(http://localhost:8888/#和http://localhost:8888/#/login),获取到跳转路由的内容,最后写到指定的目录下

相关逻辑

话不多说,直接上代码

index.ts

#!/usr/bin/env node

import * as puppeteer from "puppeteer";
import Server from "./server";
import * as fs from "fs";
import * as mkdirp from "mkdirp";
import chalk from "chalk";
import validate from "./validate";

let ssrConfigFile = process.cwd() + "/.ssrconfig.json";

const isSsrConfigExist = fs.existsSync(ssrConfigFile);

let ssrconfig = "{}";
if (isSsrConfigExist) {
  ssrconfig = fs.readFileSync(ssrConfigFile, "utf8");
}

const log = console.log;

export interface Config {
  PORT: number;
  OUTPUTDIR: string;
  INPUTDIR: string;
  routes: Array<string>;
  headless: boolean;
  HASH: boolean;
}

/**
 * params {string} PORT express服务端口, default 8888
 * params {string} OUTPUTDIR 输出目录 default dist
 * params {string} INPUTDIR 服务启动目录 default dist
 * params {array} routes 需要ssr的路由 default ['/']
 * params {boolean} headless headless mode default ture
 * params {boolean} HASH 路由模式 default hash模式
 */

let {
  PORT = 8888,
  OUTPUTDIR = "dist",
  INPUTDIR = "dist",
  routes = ["/"],
  headless = true,
  HASH = true
}: Config = JSON.parse(ssrconfig);

class Ssr {
  private index: number;
  constructor() {
    this.index = 0;
    validate({
      PORT,
      OUTPUTDIR,
      INPUTDIR,
      routes,
      headless,
      HASH
    });
  }
  async init() {
    const server = new Server(PORT, INPUTDIR);
    server.init();

    log(chalk.greenBright("初始化browser"));
    const browser = await puppeteer.launch({
      headless
    });
    if (routes.length === 0 || !routes[0]) {
      routes = ["/"];
    }
    const len = routes.length;
    routes.map((v, i) => {
      if (!v) routes.splice(i, 1);
    });
    routes.map(async (v: string) => {
      const page = await browser.newPage();
      const FRAGMENT = HASH ? "/#" : "";
      const HISTORY = v.startsWith("/") ? v : `/${v}`;
      const URL = `http://localhost:${PORT}${FRAGMENT}${HISTORY}`;
      await page.goto(URL);
      await page.waitForSelector("body");
      const content = await page.content();

      let DIR = `${process.cwd()}/${OUTPUTDIR}${HISTORY}`;
      await mkdirp(DIR, err => {
        if (err) {
          console.error(err);
        }
        const filename = v.split("/").pop() || "index";
        DIR = DIR.endsWith("/") ? DIR : DIR + "/";
        fs.writeFile(`${DIR}${filename}.html`, content, err => {
          if (err) {
            console.error(err);
          }
          this.index++;
          log(chalk.greenBright(`页面 ${DIR}${filename}.html 抓取完毕`));
          if (len === this.index) {
            log("");
            log(chalk.greenBright("🎉 所有页面抓取完毕"));
            log("");
            log(chalk.redBright("npm install -g serve"));
            log(chalk.redBright(`serve ${OUTPUTDIR}/`));
            log("");
            process.exit();
          }
        });
      });
    });
  }
}

const ssr = new Ssr();
ssr.init();

serve.ts

#!/usr/bin/env node
import * as express from "express";
import chalk from "chalk";

const log = console.log;

class Server {
  private port: number;
  private staticDir: string;
  public app: any;
  constructor(port: number, staticDir: string) {
    this.port = port;
    this.app = express();
    this.staticDir = staticDir;
  }
  init() {
    const { port, staticDir } = this;
    this.app.use(express.static(staticDir));
    this.app.listen(port, () => {
      log(chalk.greenBright(`server running at http://localhost:${port}/`));
      log("");
    });
  }
}

export default Server;

validate.ts

import { Config } from "./index";
import chalk from "chalk";
const log = console.log;

export default function validate({
  PORT,
  OUTPUTDIR,
  INPUTDIR,
  routes,
  headless,
  HASH
}: Config) {
  if (PORT < 0 || !Number.isInteger(PORT)) {
    log("");
    log(chalk.bgRedBright("PORT必须为正整数"));
    process.exit;
  }
  if (!Array.isArray(routes)) {
    log("");
    log(chalk.bgRedBright("routes必须是一个数组"));
    process.exit();
  }
  if (!typeof headless) {
    log("");
    log(chalk.bgRedBright("headless 必须是一个Boolean值"));
    process.exit();
  }
  if (!typeof HASH) {
    log("");
    log(chalk.bgRedBright("HASH 必须是一个Boolean值"));
    process.exit();
  }
}

puppeteer-ssr会读取执行命令的根目录下读取.ssrconfig.json,可按需配置参数(注: 如果没有.ssrconfig.json,则以默认参数为准)

参数 类型 说明
PORT number 服务端口号(default: 8888)
OUTPUTDIR string ssr 输出目录(default: "dist")
INPUTDIR string node 监听的静态资源目录(default: "dist")
routes Array 需要 ssr 的路由(default: ["/"])
headless boolean headless mode(default: true)
HASH boolean 路由模式(default: true)

验证

react项目经过webpack打包后的静态资源目录为: (点击查看如何快速生成react项目

demo

其中index.html文件为

demo

执行puppeteer-ssr,会有如下提示(ps: 红字为可以全局装serve这个依赖,然后在输出目录下执行,可以查看效果, 下同)

demo

我们会发现此时的index.html文件如下

demo

静态资源文件出现了和内容相关的dom节点, 然后我们就可以把该文件放在服务端上进行渲染。

那么如果我们在routes里面配了一个并不存在的路由, 如["/"", "/ssr/xixi"], puppeteer-ssr是怎样处理的呢?同样, puppeteer-ssr会根据你配置的路由,生成相对于的目录,如下

目录结构如下

demo

经测试,vue也是可以达到该效果,具体就不演示了,相关代码可在github上查看。

这里只是我在学习puppeteer的过程中对SSR理解后的一个简单实践,在写的过程中很多地方没考虑的很清楚,只是和我之前写cas自动获取cookie(详见我之前写的关于puppeteer实践的文章)的一个大思路一样,就是简单的写一个无侵入的工具来满足我在开发过程中对某些方面的需求,用工具来相对高效的完成我的开发任务,仅此而已。如有疑问或者写的有误,欢迎指正,😜