摘要:繼個實例入門并掌握二后續配置配置配置使用加快打包速度多頁面打包配置編寫編寫編寫十七配置源碼地址本節使用的代碼為基礎我們來模擬平時開發中,將打包完的代碼防止到服務器上的操作,首先打包代碼然后安裝一個插件在中配置一個命令運
繼 24 個實例入門并掌握「Webpack4」(二) 后續:
PWA 配置
TypeScript 配置
Eslint 配置
使用 DLLPlugin 加快打包速度
多頁面打包配置
編寫 loader
編寫 plugin
編寫 Bundle
十七、PWA 配置demo17 源碼地址
本節使用 demo15 的代碼為基礎
我們來模擬平時開發中,將打包完的代碼防止到服務器上的操作,首先打包代碼 npm run build
然后安裝一個插件 npm i http-server -D
在 package.json 中配置一個 script 命令
{ "scripts": { "start": "http-server dist", "dev": "webpack-dev-server --open --config ./build/webpack.dev.conf.js", "build": "webpack --config ./build/webpack.prod.conf.js" } }
運行 npm run start
現在就起了一個服務,端口是 8080,現在訪問 http://127.0.0.1:8080 就能看到效果了
如果你有在跑別的項目,端口也是 8080,端口就沖突,記得先關閉其他項目的 8080 端口,再 npm run start
我們按 ctrl + c 關閉 http-server 來模擬服務器掛了的場景,再訪問 http://127.0.0.1:8080 就會是這樣
頁面訪問不到了,因為我們服務器掛了,PWA 是什么技術呢,它可以在你第一次訪問成功的時候,做一個緩存,當服務器掛了之后,你依然能夠訪問這個網頁
首先安裝一個插件:workbox-webpack-plugin
npm i workbox-webpack-plugin -D
只有要上線的代碼,才需要做 PWA 的處理,打開 webpack.prod.conf.js
const WorkboxPlugin = require("workbox-webpack-plugin") // 引入 PWA 插件 const prodConfig = { plugins: [ // 配置 PWA new WorkboxPlugin.GenerateSW({ clientsClaim: true, skipWaiting: true }) ] }
重新打包,在 dist 目錄下會多出 service-worker.js 和 precache-manifest.js 兩個文件,通過這兩個文件就能使我們的網頁支持 PWA 技術,service-worker.js 可以理解為另類的緩存
還需要去業務代碼中使用 service-worker
在 app.js 中加上以下代碼
// 判斷該瀏覽器支不支持 serviceWorker if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker .register("/service-worker.js") .then(registration => { console.log("service-worker registed") }) .catch(error => { console.log("service-worker registed error") }) }) }
重新打包,然后運行 npm run start 來模擬服務器上的操作,最好用無痕模式打開 http://127.0.0.1:8080 ,打開控制臺
現在文件已經被緩存住了,再按 ctrl + c 關閉服務,再次刷新頁面也還是能顯示的
TypeScript配置demo18 源碼地址
TypeScript 是 JavaScript 類型的超集,它可以編譯成純 JavaScript
新建文件夾,npm init -y,npm i webpack webpack-cli -D,新建 src 目錄,創建 index.ts 文件,這段代碼在瀏覽器上是運行不了的,需要我們打包編譯,轉成 js
class Greeter { greeting: string constructor(message: string) { this.greeting = message } greet() { return "Hello, " + this.greeting } } let greeter = new Greeter("world") alert(greeter.greet())
npm i ts-loader typescript -D
新建 webpack.config.js 并配置
const path = require("path") module.exports = { mode: "production", entry: "./src/index.ts", module: { rules: [ { test: /.ts?$/, use: "ts-loader", exclude: /node_modules/ } ] }, output: { filename: "bundle.js", path: path.resolve(__dirname, "dist") } }
在 package.json 中配置 script
{ "scripts": { "build": "webpack" } }
運行 npm ruh build,報錯了,缺少 tsconfig.json 文件
當打包 typescript 文件的時候,需要在項目的根目錄下創建一個 tsconfig.json 文件
以下為簡單配置,更多詳情看官網
{ "compileerOptions": { "outDir": "./dist", // 寫不寫都行 "module": "es6", // 用 es6 模塊引入 import "target": "es5", // 打包成 es5 "allowJs": true // 允許在 ts 中也能引入 js 的文件 } }
再次打包,打開 bundle.js 文件,將代碼全部拷貝到瀏覽器控制臺上,使用這段代碼,可以看到彈窗出現 Hello,world,說明 ts 編譯打包成功
引入第三方庫npm i lodash
import _ from "lodash" class Greeter { greeting: string constructor(message: string) { this.greeting = message } greet() { return _.join() } } let greeter = new Greeter("world") alert(greeter.greet())
lodash 的 join 方法需要我們傳遞參數,但是現在我們什么都沒傳,也沒有報錯,我們使用 typescript 就是為了類型檢查,在引入第三方庫的時候也能如此,可是現在缺并沒有報錯或者提示
我們還要安裝一個 lodash 的 typescript 插件,這樣就能識別 lodash 方法中的參數,一旦使用的不對就會報錯出來
npm i @types/lodash -D
安裝完以后可以發現下劃線 _ 報錯了
需要改成 import * as _ from "lodash",將 join 方法傳遞的參數刪除,還可以發現 join 方法的報錯,這就體現了 typescript 的優勢,同理,引入 jQuery 也要引入一個 jQuery 對應的類型插件
如何知道使用的庫需要安裝對應的類型插件呢?
打開TypeSearch,在這里對應的去搜索你想用的庫有沒有類型插件,如果有只需要 npm i @types/jquery -D 即可
十九、Eslint 配置demo19 源碼地址
創建一個空文件夾,npm init -y,npm webpack webpack-cli -D 起手式,之后安裝 eslint 依賴
npm i eslint -D
使用 npx 運行此項目中的 eslint 來初始化配置,npx eslint --init
這里會有選擇是 React/Vue/JavaScript,我們統一都先選擇 JavaScript。選完后會在項目的根目錄下新建一個 .eslintrc.js 配置文件
module.exports = { env: { browser: true, es6: true }, extends: "eslint:recommended", globals: { Atomics: "readonly", SharedArrayBuffer: "readonly" }, parserOptions: { ecmaVersion: 2018, sourceType: "module" }, rules: {} }
里面就是 eslint 的一些規范,也可以定義一些規則,具體看 eslint 配置規則
在 index.js 中隨便寫點代碼來測試一下 eslint
eslint 報錯提示,變量定義后卻沒有使用,如果在編輯器里沒出現報錯提示,需要在 vscode 里先安裝一個 eslint 擴展,它會根據你當前目錄的下的 .eslintrc.js 文件來做作為校驗的規則
也可以通過命令行的形式,讓 eslint 校驗整個 src 目錄下的文件
如果你覺得某個規則很麻煩,想屏蔽掉某個規則的時候,可以這樣,根據 eslint 的報錯提示,比如上面的 no-unused-vars,將這條規則復制一下,在 .eslintrc.js 中的 rules 里配置一下,"no-unused-vars": 0,0 表示禁用,保存后,就不會報錯了,但是這種方式是適用于全局的配置,如果你只想在某一行代碼上屏蔽掉 eslint 校驗,可以這樣做
/* eslint-disable no-unused-vars */ let a = "1"
這個 eslint 的 vscode 擴展和 webpack 是沒有什么關聯的,我們現在要講的是如何在 webpack 里使用 eslint,首先安裝一個插件
npm i eslint-loader -D
在 webpack.config.js 中進行配置
/* eslint-disable no-undef */ // eslint-disable-next-line no-undef const path = require("path") module.exports = { mode: "production", entry: { app: "./src/index.js" // 需要打包的文件入口 }, module: { rules: [ { test: /.js$/, // 使用正則來匹配 js 文件 exclude: /nodes_modules/, // 排除依賴包文件夾 use: { loader: "eslint-loader" // 使用 eslint-loader } } ] }, output: { // eslint-disable-next-line no-undef publicPath: __dirname + "/dist/", // js 引用的路徑或者 CDN 地址 // eslint-disable-next-line no-undef path: path.resolve(__dirname, "dist"), // 打包文件的輸出目錄 filename: "bundle.js" // 打包后生產的 js 文件 } }
由于 webpack 配置文件也會被 eslint 校驗,這里我先寫上注釋,關閉校驗
如果你有使用 babel-loader 來轉譯,則 loader 應該這么寫
loader: ["babel-loader", "eslint-loader"]
rules 的執行順序是從右往左,從下往上的,先經過 eslint 校驗判斷代碼是否符合規范,然后再通過 babel 來做轉移
配置完 webpack.config.js,我們將 index.js 還原回之前報錯的狀態,不要使用注釋關閉校驗,然后運行打包命令,記得去 package.json 配置 script
會在打包的時候,提示代碼不合格,不僅僅是生產環境,開發環境也可以配置,可以將 eslint-loader 配置到 webpack 的公共模塊中,這樣更有利于我們檢查代碼規范
如:設置 fix 為 true,它會幫你自動修復一些錯誤,不能自動修復的,還是需要你自己手動修復
{ loader: "eslint-loader", // 使用 eslint-loader options: { fix: true } }
關于 eslint-loader,webpack 的官網也給出了配置,感興趣的朋友自己去看一看
二十、使用 DLLPlugin 加快打包速度demo20 源碼地址
本節使用 demo15 的代碼為基礎
我們先安裝一個 lodash 插件 npm i lodash,并在 app.js 文件中寫入
import _ from "lodash" console.log(_.join(["hello", "world"], "-"))
在 build 文件夾下新建 webpack.dll.js 文件
const path = require("path") module.exports = { mode: "production", entry: { vendors: ["lodash", "jquery"] }, output: { filename: "[name].dll.js", path: path.resolve(__dirname, "../dll"), library: "[name]" } }
這里使用 library,忘記的朋友可以回顧一下第十六節,自定義函數庫里的內容,定義了 library 就相當于掛載了這個全局變量,只要在控制臺輸入全局變量的名稱就可以顯示里面的內容,比如這里我們是 library: "[name]" 對應的 name 就是我們在 entry 里定義的 vendors
在 package.json 中的 script 再新增一個命令
{ "scripts": { "dev": "webpack-dev-server --open --config ./build/webpack.dev.conf.js", "build": "webpack --config ./build/webpack.prod.conf.js", "build:dll": "webpack --config ./build/webpack.dll.js" } }
運行 npm run build:dll,會生成 dll 文件夾,并且文件為 vendors.dll.js
打開文件可以發現 lodash 已經被打包到了 dll 文件中
那我們要如何使用這個 vendors.dll.js 文件呢
需要再安裝一個依賴 npm i add-asset-html-webpack-plugin,它會將我們打包后的 dll.js 文件注入到我們生成的 index.html 中
在 webpack.base.conf.js 文件中引入
const AddAssetHtmlWebpackPlugin = require("add-asset-html-webpack-plugin") module.exports = { plugins: [ new AddAssetHtmlWebpackPlugin({ filepath: path.resolve(__dirname, "../dll/vendors.dll.js") // 對應的 dll 文件路徑 }) ] }
使用 npm run dev 來打開網頁
現在我們已經把第三方模塊多帶帶打包成了 dll 文件,并使用
但是現在使用第三方模塊的時候,要用 dll 文件,而不是使用 /node_modules/ 中的庫,繼續來修改 webpack.dll.js 配置
const path = require("path") const webpack = require("webpack") module.exports = { mode: "production", entry: { vendors: ["lodash", "jquery"] }, output: { filename: "[name].dll.js", path: path.resolve(__dirname, "../dll"), library: "[name]" }, plugins: [ new webpack.DllPlugin({ name: "[name]", // 用這個插件來分析打包后的這個庫,把庫里的第三方映射關系放在了這個 json 的文件下,這個文件在 dll 目錄下 path: path.resolve(__dirname, "../dll/[name].manifest.json") }) ] }
保存后重新打包 dll,npm run build:dll
修改 webpack.base.conf.js 文件,添加 webpack.DllReferencePlugin 插件
module.exports = { plugins: [ // 引入我們打包后的映射文件 new webpack.DllReferencePlugin({ manifest: path.resolve(__dirname, "../dll/vendors.manifest.json") }) ] }
之后再 webpack 打包的時候,就可以結合之前的全局變量 vendors 和 這個新生成的 vendors.manifest.json 映射文件,然后來對我們的源代碼進行分析,一旦分析出使用第三方庫是在 vendors.dll.js 里,就會去使用 vendors.dll.js,不會去使用 /node_modules/ 里的第三方庫了
再次打包 npm run build,可以把 webpack.DllReferencePlugin 模塊注釋后再打包對比一下
注釋前 4000ms 左右,注釋后 4300ms 左右,雖然只是快了 300ms,但是我們目前只是實驗性的 demo,實際項目中,比如拿 vue 來說,vue,vue-router,vuex,element-ui,axios 等第三方庫都可以打包到 dll.js 里,那個時候的打包速度就能提升很多了
還可以繼續拆分,修改 webpack.dll.js 文件
const path = require("path") const webpack = require("webpack") module.exports = { mode: "production", entry: { lodash: ["lodash"], jquery: ["jquery"] }, output: { filename: "[name].dll.js", path: path.resolve(__dirname, "../dll"), library: "[name]" }, plugins: [ new webpack.DllPlugin({ name: "[name]", path: path.resolve(__dirname, "../dll/[name].manifest.json") // 用這個插件來分析打包后的這個庫,把庫里的第三方映射關系放在了這個 json 的文件下,這個文件在 dll 目錄下 }) ] }
運行 npm run build:dll
可以把之前打包的 vendors.dll.js 和 vendors.manifest.json 映射文件給刪除掉
然后再修改 webpack.base.conf.js
module.exports = { plugins: [ new AddAssetHtmlWebpackPlugin({ filepath: path.resolve(__dirname, "../dll/lodash.dll.js") }), new AddAssetHtmlWebpackPlugin({ filepath: path.resolve(__dirname, "../dll/jquery.dll.js") }), new webpack.DllReferencePlugin({ manifest: path.resolve(__dirname, "../dll/lodash.manifest.json") }), new webpack.DllReferencePlugin({ manifest: path.resolve(__dirname, "../dll/jquery.manifest.json") }) ] }
保存后運行 npm run dev,看看能不能成功運行
這還只是拆分了兩個第三方模塊,就要一個個配置過去,有沒有什么辦法能簡便一點呢? 有!
這里使用 node 的 api,fs 模塊來讀取文件夾里的內容,創建一個 plugins 數組用來存放公共的插件
const fs = require("fs") const plugins = [ // 開發環境和生產環境二者均需要的插件 new HtmlWebpackPlugin({ title: "webpack4 實戰", filename: "index.html", template: path.resolve(__dirname, "..", "index.html"), minify: { collapseWhitespace: true } }), new webpack.ProvidePlugin({ $: "jquery" }) ] const files = fs.readdirSync(path.resolve(__dirname, "../dll")) console.log(files)
寫完可以先輸出一下,把 plugins 給注釋掉,npm run build 打包看看輸出的內容,可以看到文件夾中的內容以數組的形式被打印出來了,之后我們對這個數組做一些循環操作就行了
完整代碼:
const path = require("path") const fs = require("fs") const webpack = require("webpack") const HtmlWebpackPlugin = require("html-webpack-plugin") const AddAssetHtmlWebpackPlugin = require("add-asset-html-webpack-plugin") // 存放公共插件 const plugins = [ // 開發環境和生產環境二者均需要的插件 new HtmlWebpackPlugin({ title: "webpack4 實戰", filename: "index.html", template: path.resolve(__dirname, "..", "index.html"), minify: { collapseWhitespace: true } }), new webpack.ProvidePlugin({ $: "jquery" }) ] // 自動引入 dll 中的文件 const files = fs.readdirSync(path.resolve(__dirname, "../dll")) files.forEach(file => { if (/.*.dll.js/.test(file)) { plugins.push( new AddAssetHtmlWebpackPlugin({ filepath: path.resolve(__dirname, "../dll", file) }) ) } if (/.*.manifest.json/.test(file)) { plugins.push( new webpack.DllReferencePlugin({ manifest: path.resolve(__dirname, "../dll", file) }) ) } }) module.exports = { entry: { app: "./src/app.js" }, output: { path: path.resolve(__dirname, "..", "dist") }, module: { rules: [ { test: /.js$/, exclude: /node_modules/, use: [ { loader: "babel-loader" } ] }, { test: /.(png|jpg|jpeg|gif)$/, use: [ { loader: "url-loader", options: { name: "[name]-[hash:5].min.[ext]", limit: 1000, // size <= 1KB outputPath: "images/" } }, // img-loader for zip img { loader: "image-webpack-loader", options: { // 壓縮 jpg/jpeg 圖片 mozjpeg: { progressive: true, quality: 65 // 壓縮率 }, // 壓縮 png 圖片 pngquant: { quality: "65-90", speed: 4 } } } ] }, { test: /.(eot|ttf|svg)$/, use: { loader: "url-loader", options: { name: "[name]-[hash:5].min.[ext]", limit: 5000, // fonts file size <= 5KB, use "base64"; else, output svg file publicPath: "fonts/", outputPath: "fonts/" } } } ] }, plugins, performance: false }
使用 npm run dev 打開網頁也沒有問題了,這樣自動注入 dll 文件也搞定了,之后還要再打包第三方庫只要添加到 webpack.dll.js 里面的 entry 屬性中就可以了
二十一、多頁面打包配置demo21 源碼地址
本節使用 demo20 的代碼為基礎
在 src 目錄下新建 list.js 文件,里面寫 console.log("這里是 list 頁面")
在 webpack.base.conf.js 中配置 entry,配置兩個入口
module.exports = { entry: { app: "./src/app.js", list: "./src/list.js" } }
如果現在我們直接 npm run build 打包,在打包自動生成的 index.html 文件中會發現 list.js 也被引入了,說明多入口打包成功,但并沒有實現多個頁面的打包,我想打包出 index.html 和 list.html 兩個頁面,并且在 index.html 中引入 app.js,在 list.html 中引入 list.js,該怎么做?
為了方便演示,先將 webpack.prod.conf.js 中 cacheGroups 新增一個 default 屬性,自定義 name
optimization: { splitChunks: { chunks: "all", cacheGroups: { jquery: { name: "jquery", // 多帶帶將 jquery 拆包 priority: 15, test: /[/]node_modules[/]jquery[/]/ }, vendors: { test: /[/]node_modules[/]/, name: "vendors" }, default: { name: "code-segment" } } } }
打開 webpack.base.conf.js 文件,將 HtmlWebpackPlugin 拷貝一份,使用 chunks 屬性,將需要打包的模塊對應寫入
// 存放公共插件 const plugins = [ new HtmlWebpackPlugin({ title: "webpack4 實戰", filename: "index.html", template: path.resolve(__dirname, "..", "index.html"), chunks: ["app", "vendors", "code-segment", "jquery", "lodash"] }), new HtmlWebpackPlugin({ title: "多頁面打包", filename: "list.html", template: path.resolve(__dirname, "..", "index.html"), chunks: ["list", "vendors", "code-segment", "jquery", "lodash"] }), new CleanWebpackPlugin(), new webpack.ProvidePlugin({ $: "jquery" }) ]
打包后的 dist 目錄下生成了兩個 html
打開 index.html 可以看到引入的是 app.js,而 list.html 引入的是 list.js,這就是 HtmlWebpackPlugin 插件的 chunks 屬性,自定義引入的 js
如果要打包三個頁面,再去 copy HtmlWebpackPlugin,通過在 entry 中配置,如果有四個,五個,這樣手動的復制就比較麻煩了,可以寫個方法自動生成 HtmlWebpackPlugin 配置
修改 webpack.base.conf.js
const path = require("path") const fs = require("fs") const webpack = require("webpack") const HtmlWebpackPlugin = require("html-webpack-plugin") const AddAssetHtmlWebpackPlugin = require("add-asset-html-webpack-plugin") const CleanWebpackPlugin = require("clean-webpack-plugin") const makePlugins = configs => { // 基礎插件 const plugins = [ new CleanWebpackPlugin(), new webpack.ProvidePlugin({ $: "jquery" }) ] // 根據 entry 自動生成 HtmlWebpackPlugin 配置,配置多頁面 Object.keys(configs.entry).forEach(item => { plugins.push( new HtmlWebpackPlugin({ title: "多頁面配置", template: path.resolve(__dirname, "..", "index.html"), filename: `${item}.html`, chunks: [item, "vendors", "code-segment", "jquery", "lodash"] }) ) }) // 自動引入 dll 中的文件 const files = fs.readdirSync(path.resolve(__dirname, "../dll")) files.forEach(file => { if (/.*.dll.js/.test(file)) { plugins.push( new AddAssetHtmlWebpackPlugin({ filepath: path.resolve(__dirname, "../dll", file) }) ) } if (/.*.manifest.json/.test(file)) { plugins.push( new webpack.DllReferencePlugin({ manifest: path.resolve(__dirname, "../dll", file) }) ) } }) return plugins } const configs = { entry: { index: "./src/app.js", list: "./src/list.js" }, output: { path: path.resolve(__dirname, "..", "dist") }, module: { rules: [ { test: /.js$/, exclude: /node_modules/, use: [ { loader: "babel-loader" } ] }, { test: /.(png|jpg|jpeg|gif)$/, use: [ { loader: "url-loader", options: { name: "[name]-[hash:5].min.[ext]", limit: 1000, // size <= 1KB outputPath: "images/" } }, // img-loader for zip img { loader: "image-webpack-loader", options: { // 壓縮 jpg/jpeg 圖片 mozjpeg: { progressive: true, quality: 65 // 壓縮率 }, // 壓縮 png 圖片 pngquant: { quality: "65-90", speed: 4 } } } ] }, { test: /.(eot|ttf|svg)$/, use: { loader: "url-loader", options: { name: "[name]-[hash:5].min.[ext]", limit: 5000, // fonts file size <= 5KB, use "base64"; else, output svg file publicPath: "fonts/", outputPath: "fonts/" } } } ] }, performance: false } makePlugins(configs) configs.plugins = makePlugins(configs) module.exports = configs
再次打包后效果相同,如果還要增加頁面,只要在 entry 中再引入一個 js 文件作為入口即可
多頁面配置其實就是定義多個 entry,配合 htmlWebpackPlugin 生成多個 html 頁面二十二、編寫 loader
demo22 源碼地址
新建文件夾,npm init -y,npm i webpack webpack-cli -D,新建 src/index.js,寫入 console.log("hello world")
新建 loaders/replaceLoader.js 文件
module.exports = function(source) { return source.replace("world", "loader") }
source 參數就是我們的源代碼,這里是將源碼中的 world 替換成 loader
新建 webpack.config.js
const path = require("path") module.exports = { mode: "development", entry: { main: "./src/index.js" }, module: { rules: [ { test: /.js/, use: [path.resolve(__dirname, "./loaders/replaceLoader.js")] // 引入自定義 loader } ] }, output: { path: path.resolve(__dirname, "dist"), filename: "[name].js" } }
目錄結構:
打包后打開 dist/main.js 文件,在最底部可以看到 world 已經被改為了 loader,一個最簡單的 loader 就寫完了
添加 optiions 屬性
const path = require("path") module.exports = { mode: "development", entry: { main: "./src/index.js" }, module: { rules: [ { test: /.js/, use: [ { loader: path.resolve(__dirname, "./loaders/replaceLoader.js"), options: { name: "xh" } } ] } ] }, output: { path: path.resolve(__dirname, "dist"), filename: "[name].js" } }
修改 replaceLoader.js 文件,保存后打包,輸出看看效果
module.exports = function(source) { console.log(this.query) return source.replace("world", this.query.name) }
打包后生成的文件也改為了 options 中定義的 name
更多的配置見官網 API,找到 Loader Interface,里面有個 this.query
如果你的 options 不是一個對象,而是按字符串形式寫的話,可能會有一些問題,這里官方推薦使用 loader-utils 來獲取 options 中的內容
安裝 npm i loader-utils -D,修改 replaceLoader.js
const loaderUtils = require("loader-utils") module.exports = function(source) { const options = loaderUtils.getOptions(this) console.log(options) return source.replace("world", options.name) }
console.log(options) 與 console.log(this.query) 輸出內容一致
如果你想傳遞額外的信息出去,return 就不好用了,官網給我們提供了 this.callback API,用法如下
this.callback( err: Error | null, content: string | Buffer, sourceMap?: SourceMap, meta?: any )
修改 replaceLoader.js
const loaderUtils = require("loader-utils") module.exports = function(source) { const options = loaderUtils.getOptions(this) const result = source.replace("world", options.name) this.callback(null, result) }
目前沒有用到 sourceMap(必須是此模塊可解析的源映射)、meta(可以是任何內容(例如一些元數據)) 這兩個可選參數,只將 result 返回回去,保存重新打包后,效果和 return 是一樣的
如果在 loader 中寫異步代碼,會怎么樣
const loaderUtils = require("loader-utils") module.exports = function(source) { const options = loaderUtils.getOptions(this) setTimeout(() => { const result = source.replace("world", options.name) return result }, 1000) }
報錯 loader 沒有返回,這里使用 this.async 來寫異步代碼
const loaderUtils = require("loader-utils") module.exports = function(source) { const options = loaderUtils.getOptions(this) const callback = this.async() setTimeout(() => { const result = source.replace("world", options.name) callback(null, result) }, 1000) }
模擬一個同步 loader 和一個異步 loader
新建一個 replaceLoaderAsync.js 文件,將之前寫的異步代碼放入,修改 replaceLoader.js 為同步代碼
// replaceLoaderAsync.js const loaderUtils = require("loader-utils") module.exports = function(source) { const options = loaderUtils.getOptions(this) const callback = this.async() setTimeout(() => { const result = source.replace("world", options.name) callback(null, result) }, 1000) } // replaceLoader.js module.exports = function(source) { return source.replace("xh", "world") }
修改 webpack.config.js,loader 的執行順序是從下到上,先執行異步代碼,將 world 改為 xh,再執行同步代碼,將 xh 改為 world
module: { rules: [ { test: /.js/, use: [ { loader: path.resolve(__dirname, "./loaders/replaceLoader.js") }, { loader: path.resolve(__dirname, "./loaders/replaceLoaderAsync.js"), options: { name: "xh" } } ] } ] }
保存后打包,在 mian.js 中可以看到已經改為了 hello world,使用多個 loader 也完成了
如果有多個自定義 loader,每次都通過 path.resolve(__dirname, xxx) 這種方式去寫,有沒有更好的方法?
使用 resolveLoader,定義 modules,當你使用 loader 的時候,會先去 node_modules 中去找,如果沒找到就會去 ./loaders 中找
const path = require("path") module.exports = { mode: "development", entry: { main: "./src/index.js" }, resolveLoader: { modules: ["node_modules", "./loaders"] }, module: { rules: [ { test: /.js/, use: [ { loader: "replaceLoader.js" }, { loader: "replaceLoaderAsync.js", options: { name: "xh" } } ] } ] }, output: { path: path.resolve(__dirname, "dist"), filename: "[name].js" } }二十三、編寫 plugin
demo23 源碼地址
首先新建一個文件夾,npm 起手式操作一番,具體的在前幾節已經說了,不再贅述
在根目錄下新建 plugins 文件夾,新建 copyright-webpack-plugin.js,一般我們用的都是 xxx-webpack-plugin,所以我們命名也按這樣來,plugin 的定義是一個類
class CopyrightWebpackPlugin { constructor() { console.log("插件被使用了") } apply(compiler) {} } module.exports = CopyrightWebpackPlugin
在 webpack.config.js 中使用,所以每次使用 plugin 都要使用 new,因為本質上 plugin 是一個類
const path = require("path") const CopyrightWebpackPlugin = require("./plugins/copyright-webpack-plugin") module.exports = { mode: "development", entry: { main: "./src/index.js" }, plugins: [new CopyrightWebpackPlugin()], output: { path: path.resolve(__dirname, "dist"), filename: "[name].js" } }
保存后打包,插件被使用了,只不過我們什么都沒干
如果我們要傳遞參數,可以這樣
new CopyrightWebpackPlugin({ name: "xh" })
同時在 copyright-webpack-plugin.js 中接收
class CopyrightWebpackPlugin { constructor(options) { console.log("插件被使用了") console.log("options = ", options) } apply(compiler) {} } module.exports = CopyrightWebpackPlugin
我們先把 constructor 注釋掉,在即將要把打包的結果,放入 dist 目錄之前的這個時刻,我們來做一些操作
apply(compiler) {} compiler 可以看作是 webpack 的實例,具體見官網 compiler-hooks
hooks 是鉤子,像 vue、react 的生命周期一樣,找到 emit 這個時刻,將打包結果放入 dist 目錄前執行,這里是個 AsyncSeriesHook 異步方法
class CopyrightWebpackPlugin { apply(compiler) { compiler.hooks.emit.tapAsync( "CopyrightWebpackPlugin", (compilation, cb) => { console.log(11) cb() } ) } } module.exports = CopyrightWebpackPlugin
因為 emit 是異步的,可以通過 tapAsync 來寫,當要把代碼放入到 dist 目錄之前,就會觸發這個鉤子,走到我們定義的函數里,如果你用 tapAsync 函數,記得最后要用 cb() ,tapAsync 要傳遞兩個參數,第一個參數傳遞我們定義的插件名稱
保存后再次打包,我們寫的內容也輸出了
compilation 這個參數里存放了這次打包的所有內容,可以輸出一下 compilation.assets 看一下
返回結果是一個對象,main.js 是 key,也就是打包后生成的文件名及文件后綴,我們可以來仿照一下
class CopyrightWebpackPlugin { apply(compiler) { compiler.hooks.emit.tapAsync( "CopyrightWebpackPlugin", (compilation, cb) => { // 生成一個 copyright.txt 文件 compilation.assets["copyright.txt"] = { source: function() { return "copyright by xh" }, size: function() { return 15 // 上面 source 返回的字符長度 } } console.log("compilation.assets = ", compilation.assets) cb() } ) } } module.exports = CopyrightWebpackPlugin
在 dist 目錄下生成了 copyright.txt 文件
之前介紹的是異步鉤子,現在使用同步鉤子
class CopyrightWebpackPlugin { apply(compiler) { // 同步鉤子 compiler.hooks.compile.tap("CopyrightWebpackPlugin", compilation => { console.log("compile") }) // 異步鉤子 compiler.hooks.emit.tapAsync( "CopyrightWebpackPlugin", (compilation, cb) => { compilation.assets["copyright.txt"] = { source: function() { return "copyright by xh" }, size: function() { return 15 // 字符長度 } } console.log("compilation.assets = ", compilation.assets) cb() } ) } } module.exports = CopyrightWebpackPlugin二十四、編寫 Bundle
demo24 源碼地址
模塊分析在 src 目錄下新建三個文件 word.js、message.js、index.js,對應的代碼:
// word.js export const word = "hello" // message.js import { word } from "./word.js" const message = `say ${word}` export default message // index.js import message from "./message.js" console.log(message)
新建 bundle.js
const fs = require("fs") const moduleAnalyser = filename => { const content = fs.readFileSync(filename, "utf-8") console.log(content) } moduleAnalyser("./src/index.js")
使用 node 的 fs 模塊,讀取文件信息,并在控制臺輸出,這里全局安裝一個插件,來顯示代碼高亮,npm i cli-highlight -g,運行 node bundle.js | highlight
index.js 中的代碼已經被輸出到控制臺上,而且代碼有高亮,方便閱讀,讀取入口文件信息就完成了
現在我們要讀取 index.js 文件中使用的 message.js 依賴,import message from "./message.js"
安裝一個第三方插件 npm i @babel/parser
@babel/parser 是 Babel 中使用的 JavaScript 解析器。
官網也提供了相應的示例代碼,根據示例代碼來仿照,修改我們的文件
const fs = require("fs") const parser = require("@babel/parser") const moduleAnalyser = filename => { const content = fs.readFileSync(filename, "utf-8") console.log( parser.parse(content, { sourceType: "module" }) ) } moduleAnalyser("./src/index.js")
我們使用的是 es6 的 module 語法,所以 sourceType: "module"
保存后運行,輸出了 AST (抽象語法樹),里面有一個 body 字段,我們輸出這個字段
const fs = require("fs") const parser = require("@babel/parser") const moduleAnalyser = filename => { const content = fs.readFileSync(filename, "utf-8") const ast = parser.parse(content, { sourceType: "module" }) console.log(ast.program.body) } moduleAnalyser("./src/index.js")
打印出了兩個 Node 節點,第一個節點的 type 是 ImportDeclaration(引入的聲明),對照我們在 index.js 中寫的 import message from "./message.js",第二個節點的 type 是 ExpressionStatement (表達式的聲明),對照我們寫的 console.log(message)
使用 babel 來幫我們生成抽象語法樹,我們再導入 import message1 from "./message1.js" 再運行
抽象語法樹將我們的 js 代碼轉成了對象的形式,現在就可以遍歷抽象語法樹生成的節點對象中的 type,是否為 ImportDeclaration,就能找到代碼中引入的依賴了
再借助一個工具 npm i @babel/traverse
const fs = require("fs") const parser = require("@babel/parser") const traverse = require("@babel/traverse").default const moduleAnalyser = filename => { const content = fs.readFileSync(filename, "utf-8") const ast = parser.parse(content, { sourceType: "module" }) traverse(ast, { ImportDeclaration({ node }) { console.log(node) } }) } moduleAnalyser("./src/index.js")
只打印了兩個 ImportDeclaration,遍歷結束,我們只需要取到依賴的文件名,在打印的內容中,每個節點都有個 source 屬性,里面有個 value 字段,表示的就是文件路徑及文件名
const fs = require("fs") const parser = require("@babel/parser") const traverse = require("@babel/traverse").default const moduleAnalyser = filename => { const content = fs.readFileSync(filename, "utf-8") const ast = parser.parse(content, { sourceType: "module" }) const dependencise = [] traverse(ast, { ImportDeclaration({ node }) { dependencise.push(node.source.value) } }) console.log(dependencise) } moduleAnalyser("./src/index.js")
保存完重新運行,輸出結果:
["./message.js", "./message1.js"]
這樣就對入口文件的依賴分析就分析出來了,現在把 index.js 中引入的 message1.js 的依賴給刪除,這里有個注意點,打印出來的文件路徑是相對路徑,相對于 src/index.js 文件,但是我們打包的時候不能是入口文件(index.js)的相對路徑,而應該是根目錄的相對路徑(或者說是絕對路徑),借助 node 的 api,引入一個 path
const fs = require("fs") const path = require("path") const parser = require("@babel/parser") const traverse = require("@babel/traverse").default const moduleAnalyser = filename => { const content = fs.readFileSync(filename, "utf-8") const ast = parser.parse(content, { sourceType: "module" }) const dependencise = [] traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(filename) console.log(dirname) dependencise.push(node.source.value) } }) // console.log(dependencise) } moduleAnalyser("./src/index.js")
輸出為 ./src,繼續修改
ImportDeclaration({ node }) { const dirname = path.dirname(filename) const newFile = path.join(dirname, node.source.value) console.log(newFile) dependencise.push(node.source.value) }
輸出為 srcmessage.js
windows 和 類 Unix(linux/mac),路徑是有區別的。windows 是用反斜杠 分割目錄或者文件的,而在類 Unix 的系統中是用的 /。
由于我是 windows 系統,所以這里輸出為 srcmessage.js,而類 Unix 輸出的為 src/message.js
.srcmessage.js 這個路徑是我們真正打包時要用到的路徑
newFile .srcmessage.js [ ".srcmessage.js" ]
既存一個相對路徑,又存一個絕對路徑
const fs = require("fs") const path = require("path") const parser = require("@babel/parser") const traverse = require("@babel/traverse").default const moduleAnalyser = filename => { const content = fs.readFileSync(filename, "utf-8") const ast = parser.parse(content, { sourceType: "module" }) const dependencise = {} traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(filename) const newFile = "." + path.join(dirname, node.source.value) console.log("newFile", newFile) dependencise[node.source.value] = newFile } }) console.log(dependencise) return { filename, dependencise } } moduleAnalyser("./src/index.js")
newFile .srcmessage.js { "./message.js": ".srcmessage.js" }
因為我們寫的代碼是 es6,瀏覽器無法識別,還是需要 babel 來做轉換
npm i @babel/core @babel/preset-env
"use strict" var _message = _interopRequireDefault(require("./message.js")) function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj } } console.log(_message.default)
const fs = require("fs") const path = require("path") const parser = require("@babel/parser") const traverse = require("@babel/traverse").default const babel = require("@babel/core") const moduleAnalyser = filename => { const content = fs.readFileSync(filename, "utf-8") const ast = parser.parse(content, { sourceType: "module" }) const dependencise = {} traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(filename) const newFile = "." + path.join(dirname, node.source.value) dependencise[node.source.value] = newFile } }) const { code } = babel.transformFromAst(ast, null, { presets: ["@babel/preset-env"] }) return { filename, dependencise, code } } const moduleInfo = moduleAnalyser("./src/index.js") console.log(moduleInfo)
分析的結果就在控制臺上打印了
{ filename: "./src/index.js", dependencise: { "./message.js": ".srcmessage.js" }, code: ""use strict"; var _message = _interopRequireDefault(require("./message.js")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } console.log(_message.default);" }
目前我們只對一個模塊進行分析,接下來要對整個項目進行分析,所以我們先分析了入口文件,再分析入口文件中所使用的依賴
依賴圖譜創建一個函數來循環依賴并生成圖譜
// 依賴圖譜 const makeDependenciesGraph = entry => { const entryModule = moduleAnalyser(entry) const graphArray = [entryModule] for (let i = 0; i < graphArray.length; i++) { const item = graphArray[i] const { dependencise } = item // 如果入口文件有依賴就去做循環依賴,對每一個依賴做分析 if (dependencise) { for (const j in dependencise) { if (dependencise.hasOwnProperty(j)) { graphArray.push(moduleAnalyser(dependencise[j])) } } } } console.log("graphArray = ", graphArray) }
將入口的依賴,依賴中的依賴全部都分析完放到 graphArray 中,控制臺輸出的打印結果
可以看到 graphArray 中一共有三個對象,就是我們在項目中引入的三個文件,全部被分析出來了,為了方便閱讀,我們創建一個 graph 對象,將分析的結果依次放入
// 依賴圖譜 const makeDependenciesGraph = entry => { const entryModule = moduleAnalyser(entry) const graphArray = [entryModule] for (let i = 0; i < graphArray.length; i++) { const item = graphArray[i] const { dependencise } = item // 如果入口文件有依賴就去做循環依賴,對每一個依賴做分析 if (dependencise) { for (const j in dependencise) { if (dependencise.hasOwnProperty(j)) { graphArray.push(moduleAnalyser(dependencise[j])) } } } } // console.log("graphArray = ", graphArray) // 創建一個對象,將分析后的結果放入 const graph = {} graphArray.forEach(item => { graph[item.filename] = { dependencise: item.dependencise, code: item.code } }) console.log("graph = ", graph) return graph }
輸出的 graph 為:
最后在 makeDependenciesGraph 函數中將 graph 返回,賦值給 graphInfo,輸出的結果和 graph 是一樣的
const graghInfo = makeDependenciesGraph("./src/index.js") console.log(graghInfo)生成代碼
現在已經拿到了所有代碼生成的結果,現在我們借助 DependenciesGraph(依賴圖譜) 來生成真正能在瀏覽器上運行的代碼
最好放在一個大的閉包中來執行,避免污染全局環境
const generateCode = entry => { // makeDependenciesGraph 返回的是一個對象,需要轉換成字符串 const graph = JSON.stringify(makeDependenciesGraph(entry)) return ` (function (graph) { })(${graph}) ` } const code = generateCode("./src/index.js") console.log(code)
我這里先把輸出的 graph 代碼格式化了一下,可以發現在 index.js 用到了 require 方法,message.js 中不僅用了 require 方法,還用 exports 對象,但是在瀏覽器中,這些都是不存在的,如果我們直接去執行,是會報錯的
let graph = { "./src/index.js": { dependencise: { "./message.js": ".srcmessage.js" }, code: ` "use strict"; var _message = _interopRequireDefault(require("./message.js")); function _interopRequireDefault(obj){ return obj && obj.__esModule ? obj : { default: obj }; } console.log(_message.default); ` }, ".srcmessage.js": { dependencise: { "./word.js": ".srcword.js" }, code: ""use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _word = require("./word.js"); var message = "say ".concat(_word.word); var _default = message; exports.default = _default;" }, ".srcword.js": { dependencise: {}, code: ""use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.word = void 0; var word = "hello"; exports.word = word;" } }
接下來要去構造 require 方法和 exports 對象
const generateCode = entry => { console.log(makeDependenciesGraph(entry)) // makeDependenciesGraph 返回的是一個對象,需要轉換成字符串 const graph = JSON.stringify(makeDependenciesGraph(entry)) return ` (function (graph) { // 定義 require 方法 function require(module) { }; require("${entry}") })(${graph}) ` } const code = generateCode("./src/index.js") console.log(code)
graph 是依賴圖譜,拿到 entry 后去執行 ./src/index.js 中的 code,也就是下面高亮部分的代碼,為了直觀我把前面輸出的 graph 代碼拿下來參考:
let graph = { "./src/index.js": { dependencise: { "./message.js": ".srcmessage.js" }, code: ` "use strict"; var _message = _interopRequireDefault(require("./message.js")); function _interopRequireDefault(obj){ return obj && obj.__esModule ? obj : { default: obj }; } console.log(_message.default); ` } }
為了讓 code 中的代碼執行,這里再使用一個閉包,讓每一個模塊里的代碼放到閉包里來執行,這樣模塊的變量就不會影響到外部的變量
return ` (function (graph) { // 定義 require 方法 function require(module) { (function (code) { eval(code) })(graph[module].code) }; require("${entry}") })(${graph}) `
閉包里傳遞的是 graph[module].code,現在 entry 也就是 ./src/index.js 這個文件,會傳給 require 中的 module 變量,實際上去找依賴圖譜中 ./src/index.js 對應的對象,然后再去找到 code 中對應的代碼,也就是下面這段代碼,被我格式化過,為了演示效果
"use strict" var _message = _interopRequireDefault(require("./message.js")) function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj } } console.log(_message.default)
但是我們會發現,這里 _interopRequireDefault(require("./message.js")) 引入的是 ./message.js 相對路徑,等到第二次執行的時候,require(module) 這里的 module 對應的就是 ./message.js
它會到 graph 中去找 ./message.js 下對應的 code,可是我們在 graph 中存的是 ".srcmessage.js" 絕對路徑,這樣就會找不到對象
因為我們之前寫代碼的時候引入的是相對路徑,現在我們要把相對路徑轉換成絕對路徑才能正確執行,定義一個 localRequire 方法,這樣當下次去找的時候就會走我們自己定義的 localRequire,其實就是一個相對路徑轉換的方法
return ` (function (graph) { // 定義 require 方法 function require(module) { // 相對路徑轉換 function localRequire(relativePath) { return require(graph[module].dependencise[relativePath]) } (function (require, code) { eval(code) })(localRequire, graph[module].code) }; require("${entry}") })(${graph}) `
我們定義了 localRequire 方法,并把它傳遞到閉包里,當執行了 eval(code) 時執行了 require 方法,就不是執行外部的 require(module) 這個方法,而是執行我們傳遞進去的 localRequire 方法
我們在分析出的代碼中是這樣引入 message.js 的
var _message = _interopRequireDefault(require("./message.js"))
這里調用了 require("./message.js"),就是我們上面寫的 require 方法,也就是 localRequire(relativePath)
所以 relativePath 就是 "./message.js"
這個方法返回的是 require(graph[module].dependencise[relativePath])
這里我把參數帶進去,就是這樣:
graph("./src/index.js").dependencise["./message.js"]
let graph = { "./src/index.js": { dependencise: { "./message.js": ".srcmessage.js" }, code: ` "use strict"; var _message = _interopRequireDefault(require("./message.js")); function _interopRequireDefault(obj){ return obj && obj.__esModule ? obj : { default: obj }; } console.log(_message.default); ` } }
對照著圖譜就能發現最終返回的就是 ".srcmessage.js" 絕對路徑,返回絕對路徑后,我們再調用 require(graph("./src/index.js").dependencise["./message.js"]) 就是執行外部定義的 require(module) 這個方法,重新遞歸的去執行,光這樣還不夠,這只是實現了 require 方法,還差 exports 對象,所以我們再定義一個 exports 對象
return ` (function (graph) { // 定義 require 方法 function require(module) { // 相對路徑轉換 function localRequire(relativePath) { return require(graph[module].dependencise[relativePath]) } var exports = {}; (function (require, exports, code) { eval(code) })(localRequire, exports, graph[module].code) return exports }; require("${entry}") })(${graph}) `
最后要記得 return exports 將 exports 導出,這樣下一個模塊在引入這個模塊的時候才能拿到導出的結果,現在代碼生成的流程就寫完了,最終返回的是一個大的字符串,保存再次運行 node bundle.js | highlight
這里我是 windows 環境,將輸出完的代碼直接放到瀏覽器里不行,我就把壓縮的代碼格式化成下面這種樣子,再放到瀏覽器里就能輸出成功了
;(function(graph) { function require(module) { function localRequire(relativePath) { return require(graph[module].dependencise[relativePath]) } var exports = {} ;(function(require, exports, code) { eval(code) })(localRequire, exports, graph[module].code) return exports } require("./src/index.js") })({ "./src/index.js": { dependencise: { "./message.js": ".srcmessage.js" }, code: ""use strict"; var _message = _interopRequireDefault(require("./message.js")); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } console.log(_message.default);" }, ".srcmessage.js": { dependencise: { "./word.js": ".srcword.js" }, code: ""use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _word = require("./word.js"); var message = "say ".concat(_word.word); var _default = message; exports.default = _default;" }, ".srcword.js": { dependencise: {}, code: ""use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.word = void 0; var word = "hello"; exports.word = word;" } })
將上面代碼放入瀏覽器的控制臺中,回車就能輸出 say hello
總結這就是打包工具打包后的內容,期間涉及了 node 知識,使用 babel 來轉譯 ast(抽象語法樹),最后的 generateCode 函數涉及到了遞歸和閉包,形參和實參,需要大家多看幾遍,加深理解
To Be Continued 個人博客 24 個實例入門并掌握「Webpack4」(一) 24 個實例入門并掌握「Webpack4」(二)文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/110207.html
摘要:前言此項目總共小節,目錄搭建項目并打包文件生產和開發模式覆蓋默認用轉譯自動生成文件處理文件圖片處理匯總處理第三方庫開發模式與開發模式和生產模式實戰打包自定義函數庫配置配置配置使用加快打包速度多頁面打包配置編寫編寫編寫前節基于漸進式教程為 前言 此項目總共 24 小節,目錄: 搭建項目并打包 JS 文件 生產和開發模式 覆蓋默認 entry/output 用 Babel 7 轉譯 ES...
摘要:代碼如下所示按照正常使用習慣,操作來實現樣式的添加和卸載,是一貫技術手段。將幫助我們進行操作。 繼 24 個實例入門并掌握「Webpack4」(一) 后續: JS Tree Shaking CSS Tree Shaking 圖片處理匯總 字體文件處理 處理第三方 js 庫 開發模式與 webpack-dev-server 開發模式和生產模式?實戰 打包自定義函數庫 九、JS Tre...
摘要:介紹背景最近和部門老大,一起在研究團隊前端新手村的建設,目的在于幫助新人快速了解和融入公司團隊,幫助零基礎新人學習和入門前端開發并且達到公司業務開發水平。 showImg(https://segmentfault.com/img/remote/1460000020063710?w=1300&h=646); 介紹 1. 背景 最近和部門老大,一起在研究團隊【EFT - 前端新手村】的建設...
摘要:五六月份推薦集合查看最新的請點擊集前端最近很火的框架資源定時更新,歡迎一下。蘇幕遮燎沈香宋周邦彥燎沈香,消溽暑。鳥雀呼晴,侵曉窺檐語。葉上初陽乾宿雨,水面清圓,一一風荷舉。家住吳門,久作長安旅。五月漁郎相憶否。小楫輕舟,夢入芙蓉浦。 五、六月份推薦集合 查看github最新的Vue weekly;請::點擊::集web前端最近很火的vue2框架資源;定時更新,歡迎 Star 一下。 蘇...
閱讀 3043·2021-09-03 10:33
閱讀 1270·2019-08-30 15:53
閱讀 2618·2019-08-30 15:45
閱讀 3378·2019-08-30 14:11
閱讀 526·2019-08-30 13:55
閱讀 2581·2019-08-29 15:24
閱讀 1906·2019-08-26 18:26
閱讀 3558·2019-08-26 13:41