webpack 快速入门 系列 - 自定义 wepack 上

其他章节请看:

webpack 快速入门 系列

自定义 wepack 上

通过“初步认识webpack”和“实战一”这 2 篇文章,我们已经学习了 webpack 最基础的知识。在继续学习 webpack 更多用法之前,我们先从更底层的角度来认识 webpack。

自定义 webpack 分上下两篇,上篇介绍 webpack 的两个核心,loader和plugin;下篇我们自己实现一个简单的 webpack。

初始化项目

loader 和 plugin 将使用此环境进行。

输入以下命名初始项目:

> mkdir webpack-demo
> cd webpack-demo
> npm init -y
> npm i -D webpack@5 webpack-cli@4

Tip: 如果出现如下错误,可以尝试运行 npm cache clean --force 来解决。

> npm i -D webpack       
npm ERR! code FETCH_ERROR
npm ERR! errno FETCH_ERROR
npm ERR! invalid json response body at http://registry.npmjs.org/webpack reason: Unexpected end of JSON input

npm ERR!     ......\npm-cache\_logs\2021-06-21T07_25_58_995Z-debug.log

> npm cache clean --force

新建两个空文件:配置文件(webpack.config.js)和入口文件(src/index.js)。

目录结构如下:

webpack-demo
  - myLoaders
  - myPlugins
  - src
    - index.js
  - webpack.config.js
  - package.json

loader

loader 的本质

loader 本质上是导出为函数的 JavaScript 模块。

我们新建一个loader,然后在配置文件中引入该loader,最后打包。示例如下:

新建loader(myLoaders/loader1.js):

/**
 *
 * @param {string|Buffer} content 源文件的内容
 * @param {object} [map] 可以被 https://github.com/mozilla/source-map 使用的 SourceMap 数据
 * @param {any} [meta] meta 数据,可以是任何内容
 */
 module.exports = function webpackLoader(content, map, meta) {
     console.log(`content=${content}`)
     // 必须有返回值
     return content
}
  

配置文件(webpack.config.js):

const path = require(‘path‘);
module.exports = {
    module: {
        rules: [
            {loader: path.join(path.resolve(__dirname, ‘myLoaders‘), ‘loader1.js‘)}
        ]
    }
};

:笔者采用 webpack v5,无需配置 entry 和 output。

入口文件(src/index.js):

console.log(‘hello‘)

打包:

// 执行 npx webpack,会将我们的脚本 src/index.js 作为 入口起点,也会生成 dist/main.js 作为 输出
> npx webpack
// loader1中的输出:
content=console.log(‘hello‘)
asset main.js 21 bytes [compared for emit] [minimized] (name: main)
./src/index.js 20 bytes [built] [code generated]

自定义 loader 确实执行了,验证通过。

我们可以使用 resolveLoader 简化 loader 的路径。请看示例:

// 配置文件
const path = require(‘path‘);
module.exports = {
    module: {
        rules: [
            {loader:‘loader1‘}
        ]
    },
    resolveLoader: {
        modules: [‘node_modules‘, path.resolve(__dirname, ‘myLoaders‘)]
    }
};

执行顺序

前面我们说到 loader 从右往左执行。例如 use: ["style-loader", "css-loader"] 会先执行 css-loader,然后再执行 style-loader,我们验证一下。

核心代码如下:

// myLoaders/loader1.js
module.exports = function webpackLoader(content, map, meta) {
     console.log(`loader1`)
     return content
}

// myLoaders/loader2.js
module.exports = function webpackLoader(content, map, meta) {
     console.log(`loader2`)
     return content
}

// 修改配置文件
module: {
    rules: [
        {use:[‘loader1‘, ‘loader2‘]}
    ]
}

打包:

> npx webpack
loader2
loader1

验证通过。虽然 loader 总是从右到左被调用。在实际(从右到左)执行 loader 之前,会先从左到右调用 loader 上的 pitch 方法。请看示例:

核心代码如下:

// loader1.js
// + 
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
     console.log(`pitch1`)
};

// loader2.js
// + 
module.exports.pitch = function (remainingRequest, precedingRequest, data) {
     console.log(`pitch2`)
};

打包:

> npx webpack
pitch1
pitch2
loader2
loader1

同步 Loaders

无论是 return 还是 this.callback() 都可以同步地返回转换后的 content 值。

this.callback() 方法则更灵活,因为它允许传递多个参数,而不仅仅是 content。请看示例:

核心代码如下:

// loader1.js
module.exports = function webpackLoader(content, map, meta) {
    console.log(`${content}`)
    return content
}

// loader2.js
module.exports = function webpackLoader(content, map, meta) {
     // node 的错误优先风格
     this.callback(null, someSyncOperation(content), map, meta);
}

function someSyncOperation(content){
     return `${content};console.log(‘i am loader2‘)`
}

// src/index.js
console.log(‘hello‘)

// 配置文件
rules: [
    {use:[‘loader1‘, ‘loader2‘]}
]

打包:

> npx webpack
// 输出
console.log(‘hello‘);console.log(‘i am loader2‘)

先执行 loader2,然后在 loader1 中输出。

异步 Loaders

对于异步 loader,使用 this.async() 来获取 callback 函数。

将同步 loaders 的例子改为异步,核心代码如下:

// loader1.js
module.exports = function webpackLoader(content, map, meta) {
    var callback = this.async();
    console.log(`${content}`)
    callback(null, content, map, meta);
}

// loader2.js
module.exports = function webpackLoader(content, map, meta) {
     const callback = this.async();
     console.log(‘i am loader2,异步处理需要3秒钟‘)
     someAsyncOperation(content, function (err, result) {
          if (err) return callback(err);
          callback(null, result, map, meta);
     });
}

function someAsyncOperation(content, callback){
     setTimeout(function(){
          callback(null, `${content};console.log(‘i am loader2‘)`)
     }, 3000)
}

打包:

> npx webpack
i am loader2,异步处理需要3秒钟
// 需要等3秒才会打印下面信息
console.log(‘hello‘);console.log(‘i am loader2‘)

Tip: 由于同步计算过于耗时,在Node.js这样的单线程环境下,建议使用异步 loader。

获取和校验参数

我们使用 loader 时,通常也会配置参数,就像这样:

{
  loader: ‘url-loader‘,
  // 配置参数
  options: {
    limit: 1024*7
  },
},

下面我们模拟一下参数的获取和验证。需要用到如下几个包:

  • loader-utils,utils for webpack loaders 。用于取得参数
  • schema-utils,validate options in loaders and plugins。用于验证参数

核心代码如下:

myLoaders/loader1.js:

const {getOptions} = require(‘loader-utils‘)
// 验证规则
const schema = require(‘./loader1-schema.json‘)
// 获取验证的方法
const {validate} = require(‘schema-utils‘)

module.exports = function webpackLoader(content, map, meta) {
    const callback = this.async();
    // 获取配置的参数
    const options = getOptions(this)
    console.log(schema)
    // { limit: ‘1024*7‘ }
    console.log(options)
    // name:loader或plugin的名字
    configuration = {name:‘loader1‘}
    // 验证
    validate(schema, options, configuration)
    // 验证通过才会输出下面的语句
    console.log(`loader1: ${content}`)
    callback(null, content, map, meta);
}

myLoaders/loader1-schema.json:

{
    "type": "object",
    "properties": {
      "limit": {
        "type": "number"
      }
    },
    "additionalProperties": false
}

配置文件:

rules: [
      {
          loader: ‘loader1‘,
          options: {
              limit: 1024*7
              // 若将值改为字符串,打包则会报错:configuration.limit should be a number
              // limit: "1024*7"
              
          },
      }
  ]

打包:

// 安装依赖包
> npm i -D loader-utils@2 schema-utils@3

> npx webpack
{
  type: ‘object‘,
  properties: { limit: { type: ‘number‘ } },
  additionalProperties: false
}
{ limit: 7168 }
loader1: console.log(‘hello‘)
asset main.js 21 bytes [compared for emit] [minimized] (name: main)
./src/index.js 20 bytes [built] [code generated]

自定义 babel-loader

在“webpack 快速入门 系列 —— 实战一”一文中,我们使用如下配置,将箭头函数打包成了普通函数。

module: {
  rules: [
    // +
    {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: ‘babel-loader‘,
          options: {
            presets: [
              [‘@babel/preset-env‘]
            ]
          }
        }
    }
  ]
}

现在我们自己实现一个 babel-loader,来完成类似的工作。

主要思路是:

  • 根据上一节(获取和校验参数)将框架搭好。包括修改配置文件的rules、新建babel-loader.js、新建babel-schema.json
  • 使用 @babel/core 中 transform() 方法将代码转换

代码如下:

修改 webpack.config.js:

...
module: {
    rules: [
        {
            test: /\.js$/,
            exclude: /node_modules/,
            use: {
                loader: ‘babel-loader‘,
                options: {
                presets: [
                    [‘@babel/preset-env‘]
                ]
                }
            }
        }
    ]
}

myLoaders/babel-loader.js:

const {getOptions} = require(‘loader-utils‘)
const schema = require(‘./babel-schema.json‘)
const {validate} = require(‘schema-utils‘)
// 包 @babel/core,Babel 编译器核心
const babelCore = require(‘@babel/core‘)
// 使用 util.promisify 方法
const util = require(‘util‘)

module.exports = function webpackLoader(content, map, meta) {
    const callback = this.async();
    const options = getOptions(this)
    configuration = {name:‘babel-loader‘}
    validate(schema, options, configuration)
    console.log(options)
    // 将异步回调转为 promise
    const transform = util.promisify(babelCore.transform) 
    transform(content, options).then(({code, map, meta}) => {
        callback(null, code, map, meta)
    }).catch((err) => {
        // node 中采用错误优先
        callback(err)
    })
}

myLoaders/babel-schema.json:

{
    "type": "object",
    "properties": {
      "presets": {
        "type": "array"
      }
    },
    "additionalProperties": false
}

src/index.js:

class People{
    constructor(name, sex){
        this.name = name;
        this.sex = sex;
    }
}
const p1 = new People(‘aaron‘, 0);
console.log(p1)

打包:

// 按照我们的 loader 依赖的包
// 如果只安装@babel/core,打包时会报错,并提示我们安装 @babel/preset-env
> npm i @babel/core@7 @babel/preset-env@7 -D
// 打包
> npx webpack
{ presets: [ [ ‘@babel/preset-env‘ ] ] }
asset main.js 201 bytes [emitted] [compared for emit] [minimized] (name: main)
./src/index.js 337 bytes [built] [code generated]

查看编译后的文件(dist/main.js):

(()=>{"use strict";var n=new function n(a,o){!function(n,a){if(!(n instanceof a))throw new TypeError("Cannot call a class as a function")}(this,n),this.name=a,this.sex=o}("aaron",0);console.log(n)})();

将这个文件在 node 中运行:

> node dist/main.js
{ name: ‘aaron‘, sex: 0 }

至此,编译后的代码等效于我们写的源码,我们的 babel-loader 验证通过。

Tip: util.Promisify() 将一个遵循常见的错误优先的回调风格的函数转为 Promise。请看示例:

const fs = require(‘fs‘)
const util = require(‘util‘);

// fs.stat是获取文件状态的异步函数
// 且以 (err, stats) => {...} 作为最后一个参数
fs.stat(‘./index.js‘, (err, stats) => {
    console.log(`文件大小:${stats.size}`)
})


// 将 fs.stat 转为 Promise
const stat = util.promisify(fs.stat);
stat(‘./index.js‘).then(stats => {
    console.log(`文件大小:${stats.size}`)
}).catch(err => {

})

插件

plugin 的本质

plugin 是一个具有 apply 方法的 JavaScript 对象。

我们新建一个 plugin,修改配置文件,然后打包。请看示例:

myPlugins/myPlugin.js:

const pluginName = ‘myPlugin‘;

class myPlugin {
  apply(compiler) {
    // tap 方法的第一个参数,应该是驼峰式命名的插件名称,建议使用常量
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log(‘webpack 构建过程开始!‘);
    });
  }
}

module.exports = myPlugin;

webpack.config.js:

const myPlugin = require(‘./myPlugins/myPlugin‘)
module.exports = {
    plugins:[
      new myPlugin()
    ]
};

打包:

> npx webpack
// myPlugin 输出
webpack 构建过程开始!
asset main.js 96 bytes [compared for emit] [minimized] (name: main)
./src/index.js 161 bytes [built] [code generated]

自定义 plugin 验证通过。

我们再来看一下 myPlugin.js,apply 方法会被 webpack compiler 调用,并且在 整个 编译生命周期都可以访问 compiler 对象。这段代码提到compilertapcompilation

由于存在某种关系,即 Compiler 扩展自 Tapable,Compiler 创建 compilation。所以下面我们依次介绍:Tapable、Compiler 和 compilation。

tapable

tapable 包导出了很多钩子类,用于创建 plugins 的钩子。

SyncHook

首选运行一个实例来感受一下。请看示例:

新建测试文件(src/tapable.test.js):

const {SyncHook} = require(‘tapable‘)

class Car {
	constructor() {
		this.hooks = {
			accelerate: new SyncHook(["newSpeed"]),
            // 参数可选
			brake: new SyncHook(),
		}
	}
}
const myCar = new Car()
// 通过 tap 给钩子添加插件
myCar.hooks.accelerate.tap("LoggerPlugin", 
    newSpeed => console.log(`Accelerating to ${newSpeed}`)
);

// 继续给钩子添加插件
myCar.hooks.accelerate.tap("LoggerPlugin2", 
    newSpeed => console.log(`Accelerating2 to ${newSpeed}`)
);

// 通过 call 触发钩子
myCar.hooks.accelerate.call(‘newSpeed 1‘)

安装依赖包,并在 node 中运行此文件:

// 安装依赖包
> npm i -D tapable@2
> nodemon src/tapable.test.js
// 输出
Accelerating to newSpeed 1
Accelerating2 to newSpeed 1

这段代码,我们使用了 tapable 其中一个钩子 SyncHook(即同步钩子)。我们通过 new SyncHook 创建钩子,通过 tap() 方法给钩子添加插件,最后我们通过 call() 方法触发钩子。

SyncBailHook

一旦有返回值(例如 return ‘‘),立即将停止执行剩余函数。请看示例(仅展示变动之处):

// 引入 SyncBailHook
const {SyncHook, SyncBailHook} = require(‘tapable‘)

// 改为 SyncBailHook 来创建钩子
accelerate: new SyncBailHook(["newSpeed"]),

// 给回调函数添加返回值:`return ‘‘`
myCar.hooks.accelerate.tap("LoggerPlugin", 
    newSpeed => {
        console.log(`Accelerating to ${newSpeed}`)
        return ‘‘
    }
);

// 输出:Accelerating to newSpeed 1
AsyncParallelHook

AsyncParallelHook 是异步并行钩子。请看完整示例:

const {AsyncParallelHook} = require(‘tapable‘)

class Car {
	constructor() {
		this.hooks = {
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
		}
	}
}
const myCar = new Car()

// 对于同步钩子,只能用 tap 来注册插件
myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
	setTimeout(() => {
        console.log(‘11‘)
        callback()
    }, 4000)
});

myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
	setTimeout(() => {
        console.log(‘22‘)
        callback()
    }, 5000)
});

myCar.hooks.calculateRoutes.callAsync(null, null, null, err => {
    if(err) return;
    console.log(‘callAsync‘)
})

/*
11
22
callAsync
*/

这段代码通过 tapAsync() 注册了两个插件,一个需要 4 秒,一个需要 5 秒。所谓并行,指的是过 4 秒输出 11,
再过 1 秒输出 22。

把第二个 tapAsync() 换成 tapPromise() 的形式,运行后也是相同的结果:

// 异步的另一种写法是 tapPromise,则无需callback,需要返回一个 promise
myCar.hooks.calculateRoutes.tapPromise("BingMapsPlugin", (source, target, routesList) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(‘22‘)
            resolve()
        }, 5000)
    })
});
AsyncSeriesHook

AsyncSeriesHook 异步串行钩子,和 AsyncParallelHook 的差异在串行上。

比如将 AsyncParallelHook 的例子,改为 AsyncSeriesHook,效果则是输出 11 后,需要再等待 5 秒才输出 22。

其他钩子就不在此介绍。

compiler 钩子

Compiler 模块是 webpack 的主要引擎,它通过 CLI 传递的所有选项, 或者 Node API,创建出一个 compilation 实例。 它扩展(extend)自 Tapable 类,用来注册和调用插件。 大多数面向用户的插件会首先在 Compiler 上注册。—— 官网

下面我们就通过几个钩子来稍微介绍下 compiler。请看示例:

仅修改 myPlugins/myPlugin.js:

const pluginName = ‘myPlugin‘;

class myPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log(‘run !‘);
    });

    // thisCompilation,初始化 compilation 时调用,在触发 compilation 事件之前调用
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      console.log(‘thisCompilation  !‘);
    });

    // emit,输出 asset 到 output 目录之前执行
    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
      setTimeout(() => {
        console.log(‘emit !‘)
        callback()
      }, 1000)
    });

    // afterEmit,输出 asset 到 output 目录之后执行
    compiler.hooks.afterEmit.tapAsync(pluginName, (compilation, callback) => {
      setTimeout(() => {
        console.log(‘afterEmit !‘)
        callback()
      }, 1000)
    });
  }
}

module.exports = myPlugin;

打包:

> npx webpack
run !
thisCompilation  !
emit !
afterEmit !
asset main.js 96 bytes [compared for emit] [minimized] (name: main)
./src/index.js 161 bytes [built] [code generated]

这段代码,我们使用了 compiler 的 4 个钩子:

  • run,属于 AsyncSeriesHook。在开始读取 records 之前调用。
  • thisCompilation,属于 SyncHook。初始化 compilation 时调用,在触发 compilation 事件之前调用。
  • emit,属于 AsyncSeriesHook。输出 asset 到 output 目录之前执行。
  • afterEmit,属于 AsyncSeriesHook。输出 asset 到 output 目录之后执行。

由于 emit 和 afterEmit 属于 AsyncSeriesHook,所以输出 "emit !",需要等1秒在输出 "afterEmit !"。

compilation

Compilation 模块会被 Compiler 用来创建新的 compilation 对象(或新的 build 对象)。 compilation 实例能够访问所有的模块和它们的依赖(大部分是循环依赖)。 它会对应用程序的依赖图中所有模块, 进行字面上的编译(literal compilation)。 在编译阶段,模块会被加载(load)、封存(seal)、优化(optimize)、 分块(chunk)、哈希(hash)和重新创建(restore)。—— 官网

Compilation 类扩展(extend)自 Tapable,并提供了以下生命周期钩子。 可以按照 compiler 钩子的相同方式来调用 tap。

我们让打包时给 dist 目录输出一个文件。请看示例:

myPlugins/myPlugin.js:

const pluginName = ‘myPlugin‘;
class myPlugin {
  apply(compiler) {
    // thisCompilation,初始化 compilation 时调用,在触发 compilation 事件之前调用
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      // additionalAssets,为 compilation 创建额外 asset
      compilation.hooks.additionalAssets.tapAsync(pluginName,  (callback) => {
        const cnt = ‘hello world‘
        // 给 assets 增加 a.txt 文件
        compilation.assets[‘a.txt‘] = {
            size(){
                return cnt.length
            },
            source(){
                return cnt
            }
        }
        callback()
      });
    });
  }
}

module.exports = myPlugin;

打包:

> npx webpack
asset main.js 96 bytes [emitted] [minimized] (name: main)
asset a.txt 11 bytes [emitted]
./src/index.js 161 bytes [built] [code generated]

Tip:如果你想看一下 compilation 到底有什么方法,可以使用 debugger 模式启动,具体做法如下:

// 在 package.json 中增加如下 debugger 命令
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "debugger": "nodemon --inspect-brk ./node_modules/webpack/bin/webpack.js"
},

// 在 myPlugin.js 中输入 debugger 打断点,例如:
const cnt = ‘hello world‘
debugger
...

// 打包
> npm run debugger

> loader-webpack@1.0.0 debugger
> nodemon --inspect-brk ./node_modules/webpack/bin/webpack.js

[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node --inspect-brk ./node_modules/webpack/bin/webpack.js`
Debugger listening on ws://127.0.0.1:9229/ddafe5b2-b184-4fed-b636-77ab74ce63f1
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.

打开浏览器,进入开发者模式,会看见一个六边形的node小图标,点击进入,后面的操作和浏览器中 debugger 相同。

上面我们生成文件的写法不好,换一种方式。请看示例:

const pluginName = ‘myPlugin‘;
const webpack = require(‘webpack‘);
// webpack5以前,webpack-sources是一个库
const {RawSource} = webpack.sources;
const {promisify} = require(‘util‘)
let {readFile} = require(‘fs‘)

readFile = promisify(readFile)

class myPlugin {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.additionalAssets.tapAsync(pluginName,  (callback) => {
        readFile(‘./src/index.js‘).then( data => {
          // emitAsset function (file, source, assetInfo = {})
          compilation.emitAsset(‘a.txt‘, new RawSource(data))
          callback()
        }).catch( err => {
          callback(err)
        })
      });
    });
  }
}

module.exports = myPlugin;

这段代码主要使用了 RawSource 以及 emitAsset 方法,运行后也会在 dist 目录中生成 a.txt 文件。

plugin 实战

需求,将 myLoaders 文件夹中 js 文件打包到 dist/myLoaders 中。

思路:

  • 使用 compilation 把文件输出到 dist/myLoaders 中
  • 使用 globby 库读取指定文件夹中的文件路径,并排除所有 json 文件

myPlugins/myPlugin.js:

const pluginName = ‘myPlugin‘;
const webpack = require(‘webpack‘);
const {RawSource} = webpack.sources;
const {promisify} = require(‘util‘)
let {readFile} = require(‘fs‘)
const globby = require(‘globby‘);
const path = require(‘path‘);

readFile = promisify(readFile)

class myPlugin {
  constructor(){
    // 取得 options,并验证
    this.options = {
      to: ‘myLoaders‘,
      // 排除 json 文件
      ignore: [‘**/**.json‘]
    }
  }
  apply(compiler) {
    compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
      compilation.hooks.additionalAssets.tapAsync(pluginName, async (callback) => {
        const {ignore, to} = this.options;
        const context = compiler.options.context;
        
        try{
          // 取得 myLoaders 目录中的文件路径,并自动排除 json 文件
          let paths = await globby(‘./myLoaders‘, {ignore});
          // 取得文件。包含文件名和文件数据
          const filePromises = paths.map(async v => {
            const absolutePath = path.resolve(context, v);
            const data = await readFile(absolutePath)
            return {
              data,
              name: path.basename(absolutePath)
            }
          })
          const files = await Promise.all(filePromises)
          // 将文件依次写入 asset 中
          files.forEach(({name, data}) => {
            const fileName = path.join(to, name)
            compilation.emitAsset(fileName, new RawSource(data))
          })
          callback()
        }catch(e){
          callback(e)
        }
      });
    });
  }
}

module.exports = myPlugin;

打包:

> npm i -D globby@11

> npx webpack
asset myLoaders\babel-loader.js 434 bytes [emitted] [minimized]
asset myLoaders\loader1.js 331 bytes [emitted] [minimized]
asset myLoaders\loader2.js 285 bytes [emitted] [minimized]
asset main.js 96 bytes [emitted] [minimized] (name: main)
./src/index.js 157 bytes [built] [code generated]

Tip:参数校验部分可自行完成。

其他章节请看:

webpack 快速入门 系列

webpack 快速入门 系列 - 自定义 wepack 上

上一篇:CSS动画


下一篇:Lucene知识总结