为什么捐赠
API 浏览器
升级指南
NEW!
创建新项目
quasar.config 配置文件
从 Webpack 项目转换
浏览器兼容性
TypeScript 支持
目录结构
命令列表
CSS 预处理器
使用 VueRouter 进行页面路由
懒加载 - 代码分割
资源处理
Boot 文件
预取特性
API 代理
配置 Vite
处理 import.meta.env
使用 Pinia 管理状态
代码检查与格式化
测试与审计
开发移动应用
Ajax 请求
开放开发服务器到公网
联系站长
Quasar CLI with Vite - @quasar/app-vite
SSR Webserver

请注意,生成的 /src-ssr 目录中包含一个名为 server.js 的文件。该文件定义了 SSR web 服务器的创建、管理和服务方式。你可以选择监听一个端口,也可以为 serverless 基础设施提供一个处理函数,完全由你决定。

结构解析

/src-ssr/server.js 文件是一个简单的 JavaScript/TypeScript 文件,用于启动 SSR web 服务器,并定义服务器如何启动、处理请求以及导出内容(如果需要导出的话)。

WARNING

/src-ssr/server.js 文件在开发环境和生产环境中都会被使用,因此请谨慎配置。要区分这两种状态,可以使用 import.meta.env.QUASAR_DEVimport.meta.env.QUASAR_PROD

下面来看看它的内容,先是 JS 项目的版本,然后是 TypeScript 的版本。根据你选择的 web 服务器框架来选择对应的示例:

Javascript
/src-ssr/server.js

/**
 * Runs in Node.js context.
 *
 * Make sure to pnpm/yarn/npm/bun install (in /src-ssr folder)
 * anything you import here.
 */

import { lstatSync } from 'node:fs'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import {
  defineSsrClose,
  defineSsrCreate,
  defineSsrInjectDevMiddleware,
  defineSsrListen,
  defineSsrRenderPreloadTag,
  defineSsrServeStaticContent
} from '#q-app'

/**
 * Create your webserver and return its instance.
 */
export const create = defineSsrCreate(async (/* { ... } */) => {
  const app = new Hono()

  if (import.meta.env.QUASAR_PROD) {
    const { compress } = await import('hono/compress')
    app.use(compress())
  }

  return app
})

/**
 * Used by Quasar SSR dev server to inject middleware into the webserver.
 * It uses it to handle Vite dev server, handle public paths, etc.
 * The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
 *
 * Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
 */
export const injectDevMiddleware = defineSsrInjectDevMiddleware(
  ({ app }) =>
    middleware => {
      app.use('*', async (c, next) => {
        const req = c.env.incoming
        const res = c.env.outgoing

        const { promise, resolve, reject } = Promise.withResolvers()

        const onDone = () => resolve(false)
        res.once('finish', onDone)
        res.once('close', onDone)

        middleware(req, res, err => {
          res.off('finish', onDone)
          res.off('close', onDone)

          if (err) reject(err)
          else resolve(true)
        })

        const passed = await promise

        if (passed) {
          /**
           * Vite skipped the request, so we let Hono continue down the chain
           */
          return next()
        }

        /**
         * Vite handled the request natively!
         *
         * Monkey-patch the native Node.js response methods.
         * The Hono Node adapter will still try to write headers and end the stream
         * when we return the dummy response. We neutralize these methods
         * so it silently does nothing instead of crashing.
         */
        const noop = () => res
        res.writeHead = noop
        res.setHeader = noop
        res.end = noop

        /**
         * Return a dummy Response.
         * This satisfies Hono's strict requirement that every branch
         * either returns a Response or calls `await next()`.
         */
        return new Response(null)
      })
    }
)

/**
 * You need to make the server listen to the indicated port
 * and return the listening instance or whatever you need to
 * close the server with.
 *
 * The "listenResult" param for the "close()" definition below
 * is what you return here.
 *
 * For production, you can instead export your
 * handler for serverless use or whatever else fits your needs.
 */
export const listen = defineSsrListen(
  async ({ app, devHttpsOptions, port }) => {
    const opts = {
      fetch: app.fetch,
      port
    }

    /**
     * For production HTTPS you can use the /src-ssr/server-assets folder
     * to place your certificates and then read them here to create the server.
     *
     * Use resolve.serverAssets('path-to-file') to get the absolute path to the file
     * or directly play with folders.serverAssets.
     */

    if (import.meta.env.QUASAR_DEV && devHttpsOptions) {
      const { createServer } = await import('node:https')
      opts.createServer = createServer
      opts.serverOptions = { ...devHttpsOptions }
    } else {
      const { createServer } = await import('node:http')
      opts.createServer = createServer
    }

    return serve(opts, info => {
      if (import.meta.env.QUASAR_PROD) {
        console.log(`🚀 Server listening at port ${info.port}`)
      }
    })
  }
)

/**
 * Should close the server and free up any resources.
 * Will be used on development only when the server needs
 * to be rebooted.
 *
 * Should you need the result of the "listen()" call above,
 * you can use the "listenResult" param.
 *
 * Can be async: defineSsrClose(async ({ listenResult }) => { ... })
 */
export const close = defineSsrClose(({ listenResult }) => listenResult.close())

const maxAge = import.meta.env.QUASAR_DEV ? 0 : 1000 * 60 * 60 * 24 * 30

/**
 * Should return a function that will be used to configure the webserver
 * to serve static content at "urlPath" from "pathToServe" folder/file.
 *
 * Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
 *
 * Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
 * Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
 */
export const serveStaticContent = defineSsrServeStaticContent(
  ({ app, resolve }) =>
    ({ urlPath, pathToServe, opts = {} }) => {
      const pubPath = resolve.public(pathToServe)
      const isDir = lstatSync(pubPath).isDirectory()

      const resolvedUrlPath = resolve.urlPath(urlPath)
      const routePath = isDir
        ? resolvedUrlPath.endsWith('*')
          ? resolvedUrlPath
          : `${resolvedUrlPath}*`
        : resolvedUrlPath

      const { maxAge: localMaxAge, ...serveOpts } = opts
      const cacheAge = localMaxAge ?? maxAge

      if (cacheAge > 0) {
        app.get(routePath, async (c, next) => {
          c.header('Cache-Control', `public, max-age=${cacheAge}`)
          await next()
        })
      }

      app.use(
        routePath,
        serveStatic({
          [isDir ? 'root' : 'path']: pubPath,
          ...serveOpts
        })
      )
    }
)

const jsRE = /\.js$/
const cssRE = /\.css$/
const woffRE = /\.woff$/
const woff2RE = /\.woff2$/
const gifRE = /\.gif$/
const jpgRE = /\.jpe?g$/
const pngRE = /\.png$/

/**
 * Should return a String with HTML output
 * (if any) for preloading indicated file
 */
export const renderPreloadTag = defineSsrRenderPreloadTag(
  (file /* , { ssrContext } */) => {
    if (jsRE.test(file)) {
      return `<link rel="modulepreload" href="${file}" crossorigin>`
    }

    if (cssRE.test(file)) {
      return `<link rel="stylesheet" href="${file}" crossorigin>`
    }

    if (woffRE.test(file)) {
      return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
    }

    if (woff2RE.test(file)) {
      return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
    }

    if (gifRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
    }

    if (jpgRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
    }

    if (pngRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
    }

    return ''
  }
)
TypeScript
/src-ssr/server.ts

/**
 * Runs in Node.js context.
 *
 * Make sure to pnpm/yarn/npm/bun install (in /src-ssr folder)
 * anything you import here.
 */

import { lstatSync } from "node:fs";
import { Hono } from "hono";
import type { IncomingMessage, ServerResponse } from "node:http";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import {
  defineSsrClose,
  defineSsrCreate,
  defineSsrInjectDevMiddleware,
  defineSsrListen,
  defineSsrRenderPreloadTag,
  defineSsrServeStaticContent
} from "#q-app";

interface NodeEnv {
  Bindings: {
    incoming: IncomingMessage;
    outgoing: ServerResponse;
  };
}

declare module "#q-app" {
  interface SsrDriver {
    app: Hono<NodeEnv>;
    listenResult: ReturnType<typeof serve>;
    request: IncomingMessage;
    response: ServerResponse;
  }
}

/**
 * Create your webserver and return its instance.
 */
export const create = defineSsrCreate(async (/* { ... } */) => {
  const app = new Hono<NodeEnv>();

  if (import.meta.env.QUASAR_PROD) {
    const { compress } = await import("hono/compress");
    app.use(compress());
  }

  return app;
});

/**
 * Used by Quasar SSR dev server to inject middleware into the webserver.
 * It uses it to handle Vite dev server, handle public paths, etc.
 * The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
 *
 * Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
 */
export const injectDevMiddleware = defineSsrInjectDevMiddleware(
  ({ app }) =>
    middleware => {
      app.use("*", async (c, next) => {
        const req = c.env.incoming;
        const res = c.env.outgoing;

        const { promise, resolve, reject } = Promise.withResolvers<boolean>();

        const onDone = () => {
          resolve(false);
        };
        res.once("finish", onDone);
        res.once("close", onDone);

        middleware(req, res, err => {
          res.off("finish", onDone);
          res.off("close", onDone);

          if (err) reject(err);
          else resolve(true);
        });

        const passed: boolean = await promise;

        if (passed) {
          /**
           * Vite skipped the request, so we let Hono continue down the chain
           */
          return next();
        }

        /**
         * Vite handled the request natively!
         *
         * Monkey-patch the native Node.js response methods.
         * The Hono Node adapter will still try to write headers and end the stream
         * when we return the dummy response. We neutralize these methods
         * so it silently does nothing instead of crashing.
         */
        const noop = () => res;
        res.writeHead = noop;
        res.setHeader = noop;
        res.end = noop;

        /**
         * Return a dummy Response.
         * This satisfies Hono's strict requirement that every branch
         * either returns a Response or calls `await next()`.
         */
        return new Response(null);
      });
    }
);

/**
 * You need to make the server listen to the indicated port
 * and return the listening instance or whatever you need to
 * close the server with.
 *
 * The "listenResult" param for the "close()" definition below
 * is what you return here.
 *
 * For production, you can instead export your
 * handler for serverless use or whatever else fits your needs.
 */
export const listen = defineSsrListen(
  async ({ app, devHttpsOptions, port }) => {
    const opts: Parameters<typeof serve>[0] = {
      fetch: app.fetch,
      port
    };

    /**
     * For production HTTPS you can use the /src-ssr/server-assets folder
     * to place your certificates and then read them here to create the server.
     *
     * Use resolve.serverAssets('path-to-file') to get the absolute path to the file
     * or directly play with folders.serverAssets.
     */

    if (import.meta.env.QUASAR_DEV && devHttpsOptions) {
      const { createServer } = await import("node:https");
      opts.createServer = createServer;
      opts.serverOptions = { ...devHttpsOptions };
    } else {
      const { createServer } = await import("node:http");
      opts.createServer = createServer;
    }

    return serve(opts, info => {
      if (import.meta.env.QUASAR_PROD) {
        console.log(`🚀 Server listening at port ${info.port}`);
      }
    });
  }
);

/**
 * Should close the server and free up any resources.
 * Will be used on development only when the server needs
 * to be rebooted.
 *
 * Should you need the result of the "listen()" call above,
 * you can use the "listenResult" param.
 *
 * Can be async: defineSsrClose(async ({ listenResult }) => { ... })
 */
export const close = defineSsrClose(({ listenResult }) => listenResult.close());

const maxAge = import.meta.env.QUASAR_DEV ? 0 : 1000 * 60 * 60 * 24 * 30;

/**
 * Should return a function that will be used to configure the webserver
 * to serve static content at "urlPath" from "pathToServe" folder/file.
 *
 * Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
 *
 * Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
 * Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
 */
export const serveStaticContent = defineSsrServeStaticContent(
  ({ app, resolve }) =>
    ({ urlPath, pathToServe, opts = {} }) => {
      const pubPath = resolve.public(pathToServe);
      const isDir = lstatSync(pubPath).isDirectory();

      const resolvedUrlPath = resolve.urlPath(urlPath);
      const routePath = isDir
        ? resolvedUrlPath.endsWith("*")
          ? resolvedUrlPath
          : `${resolvedUrlPath}*`
        : resolvedUrlPath;

      const { maxAge: maxAgeOpt, ...serveOpts } = opts;
      const cacheAge = maxAgeOpt !== void 0 ? maxAgeOpt : maxAge;

      if (cacheAge > 0) {
        app.get(routePath, async (c, next) => {
          c.header("Cache-Control", `public, max-age=${cacheAge}`);
          await next();
        });
      }

      const staticOpts: Parameters<typeof serveStatic>[0] = { ...serveOpts };
      if (isDir) {
        staticOpts.root = pubPath;
      } else {
        staticOpts.path = pubPath;
      }

      app.use(routePath, serveStatic(staticOpts));
    }
);

const jsRE = /\.js$/;
const cssRE = /\.css$/;
const woffRE = /\.woff$/;
const woff2RE = /\.woff2$/;
const gifRE = /\.gif$/;
const jpgRE = /\.jpe?g$/;
const pngRE = /\.png$/;

/**
 * Should return a String with HTML output
 * (if any) for preloading indicated file
 */
export const renderPreloadTag = defineSsrRenderPreloadTag(
  (file /* , { ssrContext } */) => {
    if (jsRE.test(file)) {
      return `<link rel="modulepreload" href="${file}" crossorigin>`;
    }

    if (cssRE.test(file)) {
      return `<link rel="stylesheet" href="${file}" crossorigin>`;
    }

    if (woffRE.test(file)) {
      return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
    }

    if (woff2RE.test(file)) {
      return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
    }

    if (gifRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`;
    }

    if (jpgRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`;
    }

    if (pngRE.test(file)) {
      return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`;
    }

    return "";
  }
);

Serverless

当将 Quasar SSR 应用部署到 serverless 架构时,你面临一个主要的架构转变:Serverless 环境不支持长时间运行的进程。

通常,Quasar 的 SSR 构建会启动一个 Node.js web 服务器并监听特定端口(例如 app.listen(3000))。在 serverless 环境中,你必须跳过这个监听阶段。取而代之的是,你的入口脚本(由 Quasar CLI 构建生成)必须导出一个无状态的请求处理函数,供 serverless 提供商在每次 HTTP 请求到来时调用。

由于构建后的 SSR 服务器本质上是一个 Hono/Express/Fastify 等应用,你的目标是将其导出为特定云服务商能够理解的格式。

我们将使用 /quasar.config > ssr > prodScriptNamedExport 属性来配置生产环境生成的 dist/index.js 文件导出什么内容:

/**
 * The named exports to use for the production generated SSR index.js script.
 * Works with `false` (no named exports), a single string (one named export),
 * or an array of strings (multiple named exports).
 *
 * Useful for serverless environments where you might want to export the
 * handler function. It creates one or more named exports from the
 * object returned by the defineSsrListen() function in /src-ssr/server file.
 *
 * @default false
 *
 * @example
 * prodScriptNamedExport: ['handler', 'ssr']
 * export const listen = defineSsrListen(() => {
 *   if (import.meta.env.QUASAR_PROD) {
 *     return { handler, ssr }
 *   }
 * })
 *
 * This will generate an SSR index.js with the following exports:
 * const { handler, ssr } = await listen({...})
 * export { handler, ssr }
 *
 * @example
 * prodScriptNamedExport: 'default'
 * export const listen = defineSsrListen(({ app }) => {
 *   if (import.meta.env.QUASAR_PROD) {
 *     return { default: app }
 *   }
 * })
 *
 * This will generate an SSR index.js with the following exports:
 * const listenResult = await listen({...})
 * export default listenResult?.default
 *
 * @example
 * prodScriptNamedExport: 'app'
 * export const listen = defineSsrListen(({ app }) => {
 *   if (import.meta.env.QUASAR_PROD) {
 *     return { app }
 *   }
 * })
 *
 * This will generate an SSR index.js with the following exports:
 * const { app } = await listen({...})
 * export { app }
 *
 * @example 'renderSsrContext' (special case)
 *
 * This will generate an SSR index.js with the following export:
 *   export { render as renderSsrContext }
 * where "render" is the same function used in
 * the /src-ssr/middlewares/render file
 */
prodScriptNamedExport?: false | string | string[];

以下是一些主流 serverless 供应商的示例:

AWS Lambda(通过 Serverless Framework 或 AWS SAM)

AWS Lambda 期望一个具有 (event, context) 签名的处理函数。由于 Quasar 输出的是一个 Node.js web 服务器应用,你不能直接将其传递给 Lambda。你需要一个像 serverless-http 这样的包装库来桥接 Lambda 的事件对象和 web 服务器的请求/响应对象。

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: serverless } = await import('serverless-http')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      handler: serverless(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'handler'
}

记得在 /src-ssr 中将 serverless-http 安装为 “dependencies”(而不是 “devDependencies”)。

Firebase Cloud Functions

Firebase Functions 构建在 Google Cloud Functions 之上。

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: functions } = await import('firebase-functions')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      ssr: functions.https.onRequest(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'ssr'
}

记得在 /src-ssr 中将 firebase-functions 安装为 “dependencies”(而不是 “devDependencies”)。

Vercel

Vercel 的 Node.js 运行时原生支持标准的 Node HTTP 请求监听器(接受 req 和 res 参数的函数)。

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      default: app
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'default'
}

Netlify Functions

Netlify Functions 的工作方式与 AWS Lambda 类似(底层就是由 AWS Lambda 驱动的)。和 AWS 一样,你需要 serverless-http 来包装你的应用。

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: serverless } = await import('serverless-http')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      handler: serverless(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'handler'
}

记得在 /src-ssr 中将 serverless-http 安装为 “dependencies”(而不是 “devDependencies”)。

Azure Functions

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    // Example with Express.js;
    // Adapt to your chosen webserver
    const { createHandler } = await import('azure-function-express')
    return {
      default: createHandler(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'default'
}

记得在 /src-ssr 中将 azure-function-express 安装为 “dependencies”(而不是 “devDependencies”)。

DigitalOcean Functions

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: serverless } = await import('serverless-http')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      main: serverless(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'main'
}

记得在 /src-ssr 中将 serverless-http 安装为 “dependencies”(而不是 “devDependencies”)。

腾讯云

// file: /src-ssr/server
export const listen = defineSsrListen(async ({ app }) => {
  if (import.meta.env.QUASAR_PROD) {
    // Crucial step: we don't listen on any port

    const { default: serverless } = await import('serverless-http')
    return {
      // Example with Express.js;
      // Adapt to your chosen webserver
      main_handler: serverless(app)
    }
  }

  // ...
})

// file: /quasar.config
ssr: {
  prodScriptNamedExport: 'main_handler'
}

记得在 /src-ssr 中将 serverless-http 安装为 “dependencies”(而不是 “devDependencies”)。