GX博客

分享个人 Full-Stack JavaScript 项目开发经验

使用Gulp自动化构建同构应用的实例分享

在 Javascript 应用开发过程中,我们需要解决很多构建过程中的不断重复的任务,还有是在每次更新发布时候执行的打包工作。目前如 Gulp、webpack 等基于 node.js 的强大工具可以帮助我们自动化完成这些重复任务。本文将分享一个个人的 Gulp 配置实例,为了解决项目起初的静态交互页面开发、图片样式和脚本的处理、同构应用的开发和打包等问题。


选择的插件和依赖

图片压缩及交错处理

  • gulp-imagemin

样式的预编译、压缩及添加前缀处理

  • node-sass
  • gulp-sass
  • gulp-autoprefixer
  • gulp-cssmin

source map 及文件名处理

  • gulp-sourcemaps
  • gulp-rename
  • gulp-hash-filename

旧哈希名文件的清理

  • gulp-clean

旧哈希文件名的替换

  • gulp-replace

Javascript的压缩和合并处理

  • gulp-uglify
  • gulp-concat

动态刷新的 web server

  • gulp-connect

babel 编译

  • @babel/core
  • @babel/plugin-proposal-class-properties
  • @babel/preset-env
  • @babel/preset-react
  • gulp-babel

以流的方式运行webpack

  • webpack
  • webpack-stream

图片压缩及交错处理

function imageMin() {
return src(
`${dir.imageDev}/**/*.{png,jpg,gif,svg}`,
{since: lastRun(imageMin)} // watch()
)
.pipe(imagemin())
.pipe(dest(dir.imagePro))
.pipe(connect.reload());
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

图片的压缩和转换比较耗时,所以上面的任务中使用了 lastRun 实现增量执行以提高任务完成效率。再而就是使用 gulp-connect 实现图片更新后的浏览器自动刷新。


样式的预编译、压缩及添加前缀处理

function cssMin() {
return src([`${dir.sass}/index.scss`, `${dir.sass}/admin.scss`])
.pipe(sass().on('error', sass.logError))
.pipe(autoprefixer())
.pipe(cssmin())
.pipe(hash({format: "{name}.{hash:8}{ext}"}))
.pipe(rename({suffix: '.min'}))
.pipe(dest(function (file) {
hashFileNames.push(file.basename);
return dir.cssPro;
}));
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

以上任务充分体现了基于流的任务构建的优势。在流中完成了 sass 编译、自动添加浏览器前缀、压缩、哈希和 min 文件名修改,最后再写入到文件系统,并记录生成的哈希文件名称。


旧哈希名文件的清理

function cleanCss() {
return src(`${dir.cssPro}/**/*.css`, {
read: false //
})
.pipe(clean({
force: true // cl
}));
}
function cleanJs() {
return src(`${dir.jsBundle}/**/*.js`, {
read: false //
})
.pipe(clean({
force: true // cl
}));
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

清除任务需要注意异步和重复执行的问题。上面的清除目录为 nginx 静态资源托管目录。


旧哈希文件名的替换

//
let hashFileNames = [];
function replaceHashFileNames() {
let stream = src([
dir.homePage,
dir.loginPage,
dir.adminPage,
dir.adminHtml
]);
for (let i = 0; i < hashFileNames.length; i++) {
//
const currentFileName = hashFileNames[i];
const reg = new RegExp(currentFileName.replace(
/([a-zA-Z]+)\.[a-zA-Z0-9]+\.min\.(css|js)/,
'$1\\.[a-zA-Z0-9]+\\.min\\.$2'), 'g');
stream = stream.pipe(replace(reg, currentFileName));
}
return stream.pipe(dest(function (file) {
return file.base;
}));
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

在创建写入到文件系统的流时候,把每个流的 file.basename 记录到 hashFileNames 数组中。最后使用 gulp-replace,按照约定的文件命名规则将新的哈希文件名替换到指定的文件内容中。该项任务一般在最后执行。


Javascript的压缩和合并处理

// options https://github.com/mishoo/UglifyJS2
function compressJs() {
return src([`${dir.jsDev}/vendor/*.js`, `${dir.jsDev}/*.js`, `!${dir.jsDev}/**/*.min.js`])
.pipe(uglify(
{
compress: {
properties: false, //
keep_fnames: true, //
drop_console: true, //
keep_fargs: true //
},
output: {
comments: false //
}
}
))
.pipe(rename({suffix: '.min'}))
.pipe(dest(function (file) {
return file.base;
}));
}
function concatFrontJs() {
return src([
`${dir.jsDev}/vendor/jquery-3.3.1.min.js`,
`${dir.jsDev}/vendor/bootstrap.front.min.js`,
`${dir.jsDev}/vendor/ie10-viewport-bug-workaround.min.js`,
`${dir.jsDev}/vendor/jquery.pagination.min.js`,
`${dir.jsDev}/vendor/jquery.qrcode.min.js`
])
.pipe(concat('front-bundle.js'))
.pipe(hash({format: "{name}.{hash:8}{ext}"}))
.pipe(rename({suffix: '.min'}))
.pipe(dest(function (file) {
hashFileNames.push(file.basename);
return dir.jsBundle;
}));
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

一般需要使用 gulp 进行合并压缩的 Javascript 都是遗留项目的非 npm 规范的包。它们相对独立,也有可能作为全局引用,所以这里压缩配置尽可能的保留函数名称和属性等。同样的,在捆绑后记录对应的哈希文件名称。


动态刷新的 web server

function connectServer() {
connect.server({
port: 8000,
index: false,
livereload: true
});
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

在构建静态交互页面时,我们需要一个可视化界面进行开发。结合 gulp-connect 和 gulp 的 watch API 可以帮我们实现静态文件服务器和浏览器的自动刷新。


babel 编译

function babelForServer() {
return src(
`${dir.frontComponents}/**/*.js`,
{since: lastRun(babelForServer)} // watch()
)
.pipe(babel({
// .babelrc
presets: ["@babel/preset-env", "@babel/preset-react"],
plugins: ["@babel/plugin-proposal-class-properties"]
}))
.pipe(dest(dir.nodeComponents))
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

该项任务的目的是当前端组件更新时,实时转译一份供服务器使用的版本,服务器端的 node-dev 开发工具在文件更新后自动重启 node.js 线程。这里需要使用增量构建的方式提高构建效率。对应的 babel 预设和插件根据项目实际情况使用。


以流的方式运行webpack

function runWebpack() {
return src(dir.entry)
.pipe(gulpWebpack(webpackConfig, webpack))
.pipe(dest(function (file) {
hashFileNames.push(file.basename);
return dir.output;
}));
}
function watchFile() {
// ...
watch(`${dir.fontSrc}/**/*.js`, {events: 'all'}, series(runWebpack, replaceHashFileNames));
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

这里的任务解决思路是,以流的方式引入入口文件,使用指定版本的 webpack 作为编译器,通过 webpack-stream 按照 webpack.config.js 配置进行编译。最后记录所有输出的哈希文件名称用于替换操作。这里并没有使用 webpack 的监听,而是使用 gulp 的监听,两者是互斥的。


package.json

{
"scripts": {
"start": "cross-env NODE_ENV=development gulp",
"build": "cross-env NODE_ENV=production gulp build"
},
"browserslist": [
"last 2 versions",
">0.2%",
"not op_mini all",
"not dead",
"ie 10"
]
}
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

在 package.json 中使用了cross-env实现跨平台兼容地设置 node.js 环境变量。并且添加了 browserslist,当然别忘了添加 .babelrc 文件。


完整的 Gulpfile.js

// gulp API
const {src, dest, watch, series, parallel, lastRun} = require('gulp');
// image
const imagemin = require('gulp-imagemin');
// css
const sass = require('gulp-sass');
const sourcemaps = require('gulp-sourcemaps');
const autoprefixer = require('gulp-autoprefixer');
const cssmin = require('gulp-cssmin');
const rename = require('gulp-rename');
const hash = require('gulp-hash-filename');
// js
const uglify = require('gulp-uglify');
const concat = require('gulp-concat');
// clean
const clean = require('gulp-clean');
// replace
const replace = require('gulp-replace');
// babel
const babel = require('gulp-babel');
// connect
const connect = require('gulp-connect');
// webpack
const webpack = require('webpack');
const gulpWebpack = require('webpack-stream');
const webpackConfig = require('./webpack.config');
// node-sass dart sass
sass.compiler = require('node-sass');
// nginx
const config = require('./config');
const nginxStaticResourcePath = config.nginxStaticResourcePath;
//
const dir = {
// html
html: 'static/html',
// images
imageDev: 'static/public/images',
imagePro: `${nginxStaticResourcePath}public/www.leeguangxing.cn/images`,
// css
sass: 'static/sass',
sassCache: 'static/.sass-cache',
cssDev: 'static/public/stylesheets',
cssPro: `${nginxStaticResourcePath}public/www.leeguangxing.cn/stylesheets`,
// js
jsDev: 'static/public/javascripts',
jsBundle: `${nginxStaticResourcePath}public/www.leeguangxing.cn/javascripts/bundle`,
// server
frontComponents: 'server/src/front',
nodeComponents: 'server/src/node',
// views
homePage: 'server/views/front/buildHtmlPage.js',
loginPage: 'server/views/front/loginPage.js',
adminPage: 'server/views/admin/buildHtmlPage.js',
adminHtml: 'admin/public/index.html',
// webpack
fontSrc: 'server/src/front',
entry: 'server/src/front/index.js',
output: `${nginxStaticResourcePath}public/www.leeguangxing.cn/javascripts/front`
};
//
let hashFileNames = [];
function connectServer() {
connect.server({
port: 8000,
index: false,
livereload: true
});
}
function imageMin() {
return src(
`${dir.imageDev}/**/*.{png,jpg,gif,svg}`,
{since: lastRun(imageMin)} // watch()
)
.pipe(imagemin())
.pipe(dest(dir.imagePro))
.pipe(connect.reload());
}
// options https://github.com/sass/node-sass#options
function compileSass() {
return src([`${dir.sass}/index.scss`, `${dir.sass}/admin.scss`])
.pipe(sourcemaps.init())
.pipe(sass({
outputStyle: 'expanded'
}).on('error', sass.logError))
.pipe(autoprefixer())
.pipe(sourcemaps.write()) // 使 sourcemaps
// .pipe(sourcemaps.write('.')) // sourcemaps
.pipe(dest(dir.cssDev))
.pipe(connect.reload());
}
function cleanCss() {
return src(`${dir.cssPro}/**/*.css`, {
read: false //
})
.pipe(clean({
force: true // cl
}));
}
function cssMin() {
return src([`${dir.sass}/index.scss`, `${dir.sass}/admin.scss`])
.pipe(sass().on('error', sass.logError))
.pipe(autoprefixer())
.pipe(cssmin())
.pipe(hash({format: "{name}.{hash:8}{ext}"}))
.pipe(rename({suffix: '.min'}))
.pipe(dest(function (file) {
hashFileNames.push(file.basename);
return dir.cssPro;
}));
}
function cleanJs() {
return src(`${dir.jsBundle}/**/*.js`, {
read: false //
})
.pipe(clean({
force: true // cl
}));
}
// options https://github.com/mishoo/UglifyJS2
function compressJs() {
return src([`${dir.jsDev}/vendor/*.js`, `${dir.jsDev}/*.js`, `!${dir.jsDev}/**/*.min.js`])
.pipe(uglify(
{
compress: {
properties: false, //
keep_fnames: true, //
drop_console: true, //
keep_fargs: true //
},
output: {
comments: false //
}
}
))
.pipe(rename({suffix: '.min'}))
.pipe(dest(function (file) {
return file.base;
}));
}
function concatFrontJs() {
return src([
`${dir.jsDev}/vendor/jquery-3.3.1.min.js`,
`${dir.jsDev}/vendor/bootstrap.front.min.js`,
`${dir.jsDev}/vendor/ie10-viewport-bug-workaround.min.js`,
`${dir.jsDev}/vendor/jquery.pagination.min.js`,
`${dir.jsDev}/vendor/jquery.qrcode.min.js`
])
.pipe(concat('front-bundle.js'))
.pipe(hash({format: "{name}.{hash:8}{ext}"}))
.pipe(rename({suffix: '.min'}))
.pipe(dest(function (file) {
hashFileNames.push(file.basename);
return dir.jsBundle;
}));
}
function concatAdminJs() {
return src([
`${dir.jsDev}/vendor/jquery-3.3.1.min.js`,
`${dir.jsDev}/vendor/bootstrap.admin.min.js`,
`${dir.jsDev}/vendor/ie10-viewport-bug-workaround.min.js`,
`${dir.jsDev}/vendor/jquery-ui.min.js`,
`${dir.jsDev}/vendor/jquery.validate.min.js`,
`${dir.jsDev}/vendor/icheck.min.js`,
`${dir.jsDev}/vendor/jquery.pagination.min.js`,
`${dir.jsDev}/login.min.js`
])
.pipe(concat('admin-bundle.js'))
.pipe(hash({format: "{name}.{hash:8}{ext}"}))
.pipe(rename({suffix: '.min'}))
.pipe(dest(function (file) {
hashFileNames.push(file.basename);
return dir.jsBundle;
}));
}
function babelForServer() {
return src(
`${dir.frontComponents}/**/*.js`,
{since: lastRun(babelForServer)} // watch()
)
.pipe(babel({
// .babelrc
presets: ["@babel/preset-env", "@babel/preset-react"],
plugins: ["@babel/plugin-proposal-class-properties"]
}))
.pipe(dest(dir.nodeComponents))
}
function replaceHashFileNames() {
let stream = src([
dir.homePage,
dir.loginPage,
dir.adminPage,
dir.adminHtml
]);
for (let i = 0; i < hashFileNames.length; i++) {
//
const currentFileName = hashFileNames[i];
const reg = new RegExp(currentFileName.replace(
/([a-zA-Z]+)\.[a-zA-Z0-9]+\.min\.(css|js)/,
'$1\\.[a-zA-Z0-9]+\\.min\\.$2'), 'g');
stream = stream.pipe(replace(reg, currentFileName));
}
return stream.pipe(dest(function (file) {
return file.base;
}));
}
function watchJs() {
return src(`${dir.jsDev}/**/*.js`)
.pipe(connect.reload());
}
function watchHtml() {
return src(`${dir.html}/**/*.html`)
.pipe(connect.reload());
}
function runWebpack() {
return src(dir.entry)
.pipe(gulpWebpack(webpackConfig, webpack))
.pipe(dest(function (file) {
hashFileNames.push(file.basename);
return dir.output;
}));
}
function watchFile() {
watch(`${dir.imageDev}/**/*.{png,jpg,gif,svg}`, {events: 'all'}, imageMin);
watch(`${dir.sass}/**/*.scss`, {events: 'all'}, compileSass);
watch(`${dir.jsDev}/**/*.js`, {event: 'all'}, watchJs);
watch(`${dir.html}/**/*.html`, {event: 'all'}, watchHtml);
watch(`${dir.frontComponents}/**/*.js`, {events: 'all'}, babelForServer);
watch(`${dir.fontSrc}/**/*.js`, {events: 'all'}, series(runWebpack, replaceHashFileNames));
}
exports.imagemin = imageMin;
exports.sass = compileSass;
exports.cssmin = series(cleanCss, cssMin, replaceHashFileNames);
exports.jsmin = compressJs;
exports.concatjs = series(cleanJs, compressJs, parallel(concatFrontJs, concatAdminJs), replaceHashFileNames);
exports.babel = babelForServer;
exports.webpack = runWebpack;
exports.build = series(
parallel(
imageMin,
series(cleanCss, cssMin),
series(cleanJs, compressJs, parallel(concatFrontJs, concatAdminJs)),
babelForServer,
runWebpack
),
replaceHashFileNames
);
exports.default = parallel(connectServer, watchFile);
הההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההההה
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

版权声明:

本文为博主原创文章,若需转载,须注明出处,添加原文链接。

https://leeguangxing.cn/blog_post_69.html