从零开始搭建Vue服务端渲染项目(二):构建

构建流程

同构应用的构建流程是比较复杂的,由于同构应用的原理,代码既会运行在服务端,也会运行在客户端,所以我们在构建时需要同时进行服务端构建和客户端构建。
服务端渲染使用的是构建完毕的server bundle来进行。客户端渲染使用的是构建完毕的client bundle来进行。
但是在服务端渲染页面时,需要提前在页面注入客户端所需的js脚本,所以需要一个客户端代码的映射文件,将client bundle引用填入html中,故最终服务端渲染时需要三样东西server bundle、html模板文件、客户端代码映射文件。正好与vue-server-renderercreateBundleRenderer的参数对应:serverBundletemplateclientManifest
所以整个流程看起来像是这样:

		 |-- =>server-entry --|                            |-- => server-bundle
		 |                    |                            |
webpack	=>                      => app.js  => src => ...  => 
         |                    |                            |    |--=>client-bundle
         |-- =>client-entry --|                            |----- 
                                                                |--=>client-manifest

webpack至少需要两套配置,一套给服务端,入口文件是server-entry.js;一套给客户端,入口文件是client-entry.js。两个入口文件都会进入app.js进行项目构建。

app.js

安装

npm i vue vue-router vuex -S

创建文件

在项目根目录下创建app.jssrc/App.vuesrc/router/index.jssrc/store/index.jssrc/views/index.vuesrc/views/about.vue

bash命令
mkdir src src/router src/store src/views
touch app.js src/App.vue src/router/index.js src/store/index.js src/views/index.vue src/views/about.vue

编写

app.js

这里放置的是整个项目的入口,需要进行Vue实例的创建、Vue-router的创建等。

  • 注意!!由于服务端运行时是多线程的,所以为了避免线程间的交叉污染,需要为每一个线程都创建一个全新的Vue实例,故使用工厂函数的方式创建Vue实例!!
  • 再注意!! vue-routervuex的实例也是一样需要避免交叉污染,需要使用工厂函数!!
  • 最后注意!!这里需要使用es module而不是nodejs的commonJS2!!
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './src/App.vue';
//router需要避免线程交叉污染,使用工厂函数
import createRouter from './src/router';
//store也需要避免线程交叉污染,使用工厂函数
import createStore from './src/store';

export default function createApp(context = {}) {
  Vue.use(VueRouter);

  const router = createRouter();
  const store = createStore();

  const app = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount('#app');

  //导出 为 server-entey 和 client-entry 使用
  return { app, router, store };
}

router/index.js

import VueRouter from 'vue-router';

export default function createRouter(context) {
  const routes = [
    {
      path: '/',
      component: () => import('../views/index.vue'),
    },
    {
      path: '/about',
      component: () => import('../views/about.vue'),
    },
  ];
  const router = new VueRouter({
    routes,
    mode: 'history',
  });
  return router;
}

store/index.js

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default function createStore() {
  return new Vuex.Store({
    //!!!非常重要,state必须为一个函数!!!!
    state: () => ({
      mockData: 'mockData',
    }),
  });
}

App.vue

<template>
  <div id="app">
    <router-link to="/">home</router-link>
    <router-link to="/about">about</router-link>
    <router-view></router-view>
  </div>
</template>

<script>
export default {};
</script>

<style></style>

views/index.vue

<template>
  <div>this is home</div>
</template>

<script>
export default {};
</script>

<style></style>

views/about.vue

<template>
  <div>this is about</div>
</template>

<script>
export default {};
</script>

<style></style>

server-entry

新建文件server/ssr/server-entry.js

touch server/ssr/server-entry.js

服务端构建入口,由于需要防止线程交叉污染,也是导出一个工厂函数

  • 由于路由可能存在异步路由(路由懒加载),故需要返回一个promise对象等待异步路由加载完毕。
  • 工厂函数需要接受一个context上下文对象,还记得renderToString的第一个参数吗?就是它
  • 此处也要使用es module
import createApp from '../../app';

export default async (context) => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp(context);
    app.$store = store;
    //vue-router需要从上下文中取出当前服务端被请求路径
    router.push(context.url);

    router.onReady(() => {
      resolve(app);
    }, reject);
  });
};

这里有一步操作是从上下文对象中取出url,所以我们需要在renderToString调用时传入url,于是更新server/ssr/index.js的代码:

const fs = require('fs');
const log4js = require('log4js');
const logger = log4js.getLogger();
const resolve = (path) => require('path').resolve(__dirname, path);
const { createBundleRenderer } = require('vue-server-renderer');
const template = fs.readFileSync(resolve('../../index.template.html'));
//创建一个服务端渲染器
const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest,
});

module.exports = (server, router) => {
  router.get('/(.*)', async (ctx, next) => {
    try {
      //渲染HTML
      const html = await renderer.renderToString({ url: ctx.url });
      ctx.body = html;
    } catch (e) {
      //渲染失败
      logger.error(e);
      await next();
    }
  });
};

client-entry

客户端入口就比较简单啦,不需要考虑线程交叉污染,新建文件/client/client-entry.js,直接上代码吧。

mkdir client
touch client/client-entry.js
/**
 * 同构应用的客户端入口
 */
import createApp from '../app';

const { app, router, store } = createApp({});
app.$store = store;

router.onReady(() => {
  app.$mount('#app');
});

构建

现在我们终于要开始构建啦~

说在前面

构建过程中各种插件、依赖的版本会有冲突,如果发生一些莫名其妙的错误你就要考虑到是不是版本的问题啦。本人在附录放了一张依赖版本表,需要的自取~

安装

npm i webpack webpack-cli webpack-merge -D

版本号:

    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.9",
    "webpack-merge": "^4.2.2",

创建文件

根目录创建build文件夹,接下来创建build/building-config.jsbuild/webpack.base.jsbuild/webpack.loc.jsbuild/webpack.pro.jsbuild/webpack.client.jsbuild/webpack.server.js
根目录创建env文件夹,用于放置各种环境变量,创建env/local.js
新建babel.config.js,babel配置文件

bash命令:
mkdir build
touch build/building-config.js build/webpack.base.js build/webpack.loc.js build/webpack.pro.js build/webpack.client.js build/webpack.server.js
mkdir env
touch env/local.env.js env/prodution.env.js
touch babel.config.js

env/local.env.js

这里放置本地开发环境的环境变量:

'use strict';

module.exports = {
  NODE_ENV: '"local"',
  STATIC_PREFIX: '"/dist/"',
};

env/prodution.env.js

这里放置生产环境的环境变量:

'use strict';

module.exports = {
  NODE_ENV: '"production"',
  STATIC_PREFIX: '"/dist/"'
};

babel.config.js

根据自己需要配吧,我这里配了个vantUI的SSR适配:

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false,
        targets: {
          browsers: ['> 1%', 'last 2 versions', 'not ie <= 8']
        }
      }
    ]
  ],
  plugins: [
    '@babel/plugin-transform-runtime',
    [
      'import',
      {
        libraryName: 'vant',
        style: true
      },
      'vant'
    ]
  ]
};

building-config.js

主要是一些构建的配置,比如说babel的配置文件存放位置,以及打包后生成的路径

const resolve = (path) => require('path').resolve(__dirname, path);

module.exports = {
  babelConfig: require(resolve('../babel.config.js')),
  assetsRoot: resolve('../server/public/dist'),
};

可以看到我们最后打包到的路径是server/public/dist,故需要让服务器打开server/public的访问,这里需要使用koa-static插件:

npm i koa-static -S

更新server/server.js代码:

const Koa = require('koa');
const serve = require('koa-static');
const router = require('koa-router')();
const config = require(`./config/${process.env.NODE_ENV}`);
const ssrMixin = require('./ssr');
const resolve = (path) => require('path').resolve(__dirname, path);
const app = new Koa();

//静态资源托管
app.use(serve(resolve('./public')));

//SSR逻辑
ssrMixin(app, router);

//koa-router中间件
app.use(router.routes()).use(router.allowedMethods());

app.listen(config.port);

webpack.base.js

webpack的基础配置文件用于给其他配置文件merge使用,这里配置比较简单。
需要注意的是filenamechunkFilename不要加其他任何插值,只能用[name],至于[hash]之类的之后在生产环境的时候再另外配置。

const VueLoaderPlugin = require('vue-loader/lib/plugin');
const buildingConf = require('./building-config');
const resolve = (path) => require('path').resolve(__dirname, path);

module.exports = {
  output: {
    //打包后生成的文件夹
    path: buildingConf.assetsRoot,
    //静态目录,可以直接从这里取文件
    publicPath: '/dist',
    //打包后生成的文件名
    filename: 'js/[name].js',
    chunkFilename: 'js/[name].js',
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.json', '.vue'],
    alias: {
      '@': resolve('../src'),
      '~@': resolve('../src'),
    },
  },
  plugins: [new VueLoaderPlugin()],
};

webpack.loc.js

这里是本地开发环境的webpack配置文件:

const merge = require('webpack-merge');
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin');
const baseConfig = require('./webpack.base');
const webpack = require('webpack');
const env = require('../env/local.env');
const resolve = (path) => require('path').resolve(__dirname, path);

let devConfig = merge(baseConfig, {
  //打包入口
  entry: {
    app: [resolve('../client/client-entry.js')],
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.less$/,
        use: [
          'vue-style-loader',
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              config: {
                path: resolve('../src'),
              },
            },
          },
          'less-loader',
        ],
      },
      {
        //页面中import css文件打包需要用到
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              config: {
                path: resolve('../src'),
              },
            },
          },
        ],
      },
      //添加ts文件解析
      {
        test: /\.tsx?$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
        options: {
          appendTsSuffixTo: [/\.vue$/],
        },
      },
      {
        test: /\.(png|jpe?g|gif|svg|webp)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10240,
          name: 'img/[name].[hash:7].[ext]',
        },
      },
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'media/[name].[hash:7].[ext]',
        },
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'fonts/[name].[hash:7].[ext]',
        },
      },
    ],
  },
  plugins: [
    //线程变量的配置
    new webpack.DefinePlugin({
      process: {
        env,
      },
    }),
    new FriendlyErrorsPlugin(),
  ],
  mode: 'development',
  stats: 'errors-only',
  devtool: 'eval-source-map',
});

module.exports = devConfig;

emmm,其实就是webpack没什么好说的,不会webpack的出门右转B站,有很多webpack的教学视频。

webpack.pro.js

生产环境的打包,涉及到一些css抽离、代码分割、压缩、gzip、css兼容性、babel。webpack不过关的同学请出门右转B站,不想了解详情的同学直接复制就好,然后安装相应的依赖。(依赖版本放在本篇附录)
具体一些环节我都写道注释里面啦,你们自己看看叭。
哎哟妈呀这个生产环境的配置可是配死我了,可是花了我整整两天!

const merge = require('webpack-merge');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
  .BundleAnalyzerPlugin;
const ExtractCssChunksPlugin = require('extract-css-chunks-webpack-plugin');
const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin');
const CompressionWebpackPlugin = require('compression-webpack-plugin');
const baseConfig = require('./webpack.base');
const buildingConf = require('./building-config');
const resolve = (path) => require('path').resolve(__dirname, path);

let prodConfig = merge(baseConfig, {
  //打包入口
  entry: {
    app: ['@babel/polyfill', resolve('../client/client-entry.js')],
  },
  output: {
    filename: 'js/[name].[contenthash].js',
    chunkFilename: 'js/[name].[contenthash].js',
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.less$/,
        use: [
          'vue-style-loader',
          {
            loader: ExtractCssChunksPlugin.loader,
          },
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              config: {
                path: resolve('../src'),
              },
            },
          },
          'less-loader',
        ],
      },
      {
        //页面中import css文件打包需要用到
        test: /\.css$/,
        use: [
          {
            loader: ExtractCssChunksPlugin.loader,
          },
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              config: {
                path: resolve('../src'),
              },
            },
          },
        ],
      },
      //添加ts文件解析
      {
        test: /\.tsx?$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
        options: {
          appendTsSuffixTo: [/\.vue$/],
        },
      },
      {
        test: /\.(png|jpe?g|gif|svg|webp)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10240,
          name: 'img/[name].[hash:7].[ext]',
          esModule: false,
        },
      },
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'media/[name].[hash:7].[ext]',
        },
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'fonts/[name].[hash:7].[ext]',
        },
      },
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader',
            options: buildingConf.babelConfig,
          },
        ],
        /* 排除模块安装目录的文件 */
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    //css抽离
    new ExtractCssChunksPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].chunk.css',
    }),
    //压缩css
    new OptimizeCSSPlugin(),
    //需要分析打包结果的话请开启这个注释!
    //new BundleAnalyzerPlugin(),
    new CompressionWebpackPlugin(),
  ],
  optimization: {
    splitChunks: {
      name: 'vendors',
      /**
       * chunks可以填写三个值:initial,async,all
       * initial: 对于匹配文件,非动态模块打包进该vendor,动态模块优化打包
       * async: 对于匹配文件,动态模块打包进该vendor,非动态模块不进行优化打包
       * all: 匹配文件无论是否动态模块,都打包进该vendor
       */
      chunks: 'initial',
      cacheGroups: {
        //将引用到2次以上的模块打包到common bundle
        common: {
          chunks: 'initial',
          name: 'common',
          minSize: 0,
          minChunks: 2, // 重复2次才能打包到此模块
        },
        async: {
          test: /node_modules/,
          name: 'vendors-async',
          chunks: 'async',
        },
      },
    },
    minimizer: [
      //压缩js
      new UglifyJsPlugin({
        uglifyOptions: {
          output: {
            comments: false,
          },
        },
      }),
    ],
  },
  devtool: 'none',
  mode: 'production',
});

module.exports = prodConfig;

webpack.client.js

客户端构建配置文件。

  • 需要根据不同的环境去merge不同的webpack配置。
  • 需要在环境变量中注入一个isBrowser的变量,值为true,表明现在是浏览器环境。
  • plugins列表中需要加入一个vue-server-renderer/client-plugin插件,用于生成client-manifest文件。
const merge = require('webpack-merge');
const webpack = require('webpack');
const WebpackBar = require('webpackbar');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const env = require(`../env/${process.env.NODE_ENV}.env`);
let config; //动态导入不同环境的配置
if (process.env.NODE_ENV === 'local') {
  config = require(`./webpack.loc`);
} else {
  config = require(`./webpack.pro`);
}

module.exports = merge(config, {
  output: {
    publicPath: env.STATIC_PREFIX.replace(/"/g, ''),
  },
  plugins: [
    new webpack.DefinePlugin({
      process: {
        env,
        isBrowser: true,
      },
    }),
    // 此插件在输出目录中生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin(),
    new WebpackBar({
      name: 'client',
      color: '#00B101',
    }),
  ],
});

webpack.server.js

服务器构建配置文件,要稍微复杂一些些哦~

  • 也是需要根据不同的环境去merge不同的webpack配置。
  • targetnode,表明当前为node环境并且会告知vue-loader需要面向服务端输送代码。
  • output.libraryTargetcommonjs2,表明以commonjs2模块输出代码。
  • externals中使用webpack-node-externals插件,屏蔽nodejs原生模块,如fs等。
  • plugins中要添加vue-server-renderer/server-plugin插件,用于生成server-bundle
  • devtooleval-source-map,用于日志记录友好的报错信息。
const merge = require('webpack-merge');
const webpack = require('webpack');
const nodeExternals = require('webpack-node-externals');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const WebpackBar = require('webpackbar');
const resolve = (path) => require('path').resolve(__dirname, path);
const env = require(`../env/${process.env.NODE_ENV}.env`);
let config; //动态导入不同环境的配置
if (process.env.NODE_ENV === 'local') {
  config = require(`./webpack.loc`);
} else {
  config = require(`./webpack.pro`);
}

module.exports = merge(config, {
  //打包入口
  entry: [resolve('../server/ssr/server-entry.js')],
  // 这允许 webpack 以 Node 适用方式处理模块加载
  // 并且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',
  output: {
    publicPath: env.STATIC_PREFIX.replace(/"/g, ''),
    filename: 'server-bundle.js',
    // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
    libraryTarget: 'commonjs2',
  },
  // 不打包 node_modules 第三方包,而是保留 require 方式直接加载
  externals: [
    nodeExternals({
      // 白名单中的资源依然正常打包
      allowlist: [/\.css$/,/vant\/lib/],
    }),
  ],
  plugins: [
    new webpack.optimize.LimitChunkCountPlugin({
      maxChunks: 1,
    }),
    new webpack.DefinePlugin({
      process: {
        env,
        isBrowser: false,
      },
    }),
    // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
    // 默认文件名为 `vue-ssr-server-bundle.json`
    new VueSSRServerPlugin(),
    new WebpackBar({
      name: 'server',
      color: '#F3A702',
    }),
  ],
  devtool: 'eval-source-map',
});

编写构建脚本

"scripts": {
    "local": "cross-env NODE_ENV=local node server/server",
    "prod": "cross-env NODE_ENV=production node server/server",
    "build-prod": "rm -rf server/public/dist && cross-env NODE_ENV=production webpack --config build/webpack.client.js && cross-env NODE_ENV=production webpack --config build/webpack.server.js"
}

local启动本地开发环境。
build-prod打包生产环境
prod启动生产环境部署项目,必须在build-prod之后运行

注意此时先不要启动npm run local,因为dev-server还没有搭建完毕(下一篇会教大家搭建一个本地开发环境),我们先试试生产环境构建,这时候你在终端输入npm run build-prod之后可以看到打包后的文件啦,已经在server/public/dist中可以看到clientManifestserverBundle啦~
clientManifest => vue-ssr-client-manifest.jsonserverBundle => vue-ssr-server-bundle.json

现在我们启动npm run prod是没用滴,因为我们还没有讲renderer配置完呢~,没事,我们现在就来配置。

部署

打开server/ssr/index.js
由于我们之后对于本地开发环境的处理和部署环境不一样(下一篇会教大家搭建一个本地开发环境),开发环境需要使用dev-server进行模块热替换啥的,所以我们这里也需要分环境获取不同滴renderer

const fs = require('fs');
const log4js = require('log4js');
const logger = log4js.getLogger();
const resolve = (path) => require('path').resolve(__dirname, path);
const { createBundleRenderer } = require('vue-server-renderer');
const template = fs.readFileSync(resolve('../../index.template.html'), 'utf-8');

//创建一个服务端渲染器
let renderer;
//本地开发环境
if (process.env.NODE_ENV === 'local') {
  //这里先空着叭
} else {
  //部署环境
  const serverBundle = require('../public/dist/vue-ssr-server-bundle.json');
  const clientManifest = require('../public/dist/vue-ssr-client-manifest.json');
  renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest,
  });
}

module.exports = (server, router) => {
  router.get('/(.*)', async (ctx, next) => {
    try {
      //渲染HTML
      const html = await renderer.renderToString({ url: ctx.url });
      ctx.body = html;
    } catch (e) {
      //渲染失败
      logger.error(e);
      await next();
    }
  });
};

接下来你就可以使用npm run prod来运行项目啦~

Let’s Rock&Roll!!!

但是这只是部署环境的构建流程,我们在本地开发的时候总不可能一旦有改动就要整个项目重新打包构建吧?这样效率太低了,于是我下一篇就会教大家怎么搭建一个本地的开发环境,每次改动不再需要整个项目打包啦。未完待续哦…

附录

依赖版本表:

"devDependencies": {
    "@babel/core": "^7.13.10",
    "@babel/plugin-transform-runtime": "^7.13.10",
    "@babel/polyfill": "^7.12.1",
    "@babel/preset-env": "^7.13.10",
    "autoprefixer": "^9.7.0",
    "babel-loader": "^8.2.2",
    "babel-plugin-import": "^1.13.3",
    "chokidar": "^3.5.1",
    "compression-webpack-plugin": "^5.0.1",
    "copy-webpack-plugin": "^4.0.1",
    "css-loader": "^5.1.3",
    "extract-css-chunks-webpack-plugin": "^4.9.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^6.2.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "koa-webpack-dev-middleware": "^2.0.2",
    "koa-webpack-hot-middleware": "^1.0.3",
    "less": "^3.10.3",
    "less-loader": "^5.0.0",
    "optimize-css-assets-webpack-plugin": "^5.0.4",
    "postcss-import": "^11.0.0",
    "postcss-loader": "^2.0.8",
    "postcss-url": "^7.2.1",
    "style-loader": "^2.0.0",
    "ts-loader": "^8.0.18",
    "typescript": "^4.2.3",
    "uglifyjs-webpack-plugin": "^2.2.0",
    "url-loader": "^0.5.8",
    "vue-loader": "^15.9.6",
    "vue-style-loader": "^4.1.3",
    "vue-template-compiler": "^2.6.12",
    "webpack": "^4.43.0",
    "webpack-bundle-analyzer": "^4.4.0",
    "webpack-cli": "^3.3.9",
    "webpack-merge": "^4.2.2",
    "webpack-node-externals": "^2.5.2",
    "webpackbar": "^5.0.0-3"
  },
  "dependencies": {
    "cross-env": "^7.0.3",
    "koa": "^2.13.1",
    "koa-router": "^10.0.0",
    "koa-static": "^5.0.0",
    "log4js": "^6.3.0",
    "vue": "^2.6.12",
    "vue-router": "^3.5.1",
    "vue-server-renderer": "^2.6.12",
    "vuex": "^3.6.2"
  }
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱学习的前端小黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值