webpack4主流程源碼閱讀,以及動手實現一個簡單的webpack

簡單概述 Webpack 總體運行流程

  1. 讀取參數
  2. 實例化 Compiler
  3. entryOption 階段,讀取入口文件
  4. Loader 編譯對應文件,解析成 AST
  5. 找到對應依賴,遞歸編譯處理,生成 chunk
  6. 輸出到 dist

webpack 打包主流程源碼閱讀

經過打斷點的方式閱讀源碼,來看一下命令行輸入 webpack 的時候都發生了什麼?P.S. 如下的源碼流程分析都基於 webpack4前端

先附上一張本身繪製的執行流程圖node

初始化階段

  1. 初始化參數( webpack.config.js+shell options

webpack 的幾種啓動方式webpack

  • 經過 webpack-cli執行 會走到 ./node_modules/.bin/webpack-cli(執行)
  • 經過 shell 執行 webpack ,會走到 ./bin/webpack.js
  • 經過 require("webpack")執行 會走到 ./node_modules/webpack/lib/webpack.js

追加 shell 命令的參數,如-p , -w,經過 yargs 解析命令行參數convert-yargs 把命令行參數轉換成 Webpack 的配置選項對象 同時實例化插件 new Plugin()web

  1. 實例化 Compiler

閱讀完整源碼點擊這裏:webpack.jsshell

// webpack入口
const webpack = (options, callback) => {
  let compiler
  // 實例Compiler
  if (Array.isArray(options)) {
    // ...
    compiler = createMultiCompiler(options)
  } else {
    compiler = createCompiler(options)
  }
  // ...
  // 若options.watch === true && callback 則開啓watch線程
  if (watch) {
    compiler.watch(watchOptions, callback)
  } else {
    compiler.run((err, stats) => {
      compiler.close((err2) => {
        callback(err || err2, stats)
      })
    })
  }
  return compiler
}

webpack 的入口文件其實就實例了 Compiler 並調用了 run 方法開啓了編譯,數組

  1. 註冊 NodeEnvironmentPlugin 插件,掛載 plugin 插件,使用 WebpackOptionsApply 初始化基礎插件

在此期間會 apply 全部 webpack 內置的插件,爲 webpack 事件流掛上自定義鉤子瀏覽器

源碼仍然在webpack.js文件緩存

const createCompiler = (rawOptions) => {
  // ...省略代碼
  const compiler = new Compiler(options.context)
  compiler.options = options
  //應用Node的文件系統到compiler對象,方便後續的文件查找和讀取
  new NodeEnvironmentPlugin({
    infrastructureLogging: options.infrastructureLogging,
  }).apply(compiler)
  // 加載插件
  if (Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      //  依次調用插件的apply方法(默認每一個插件對象實例都須要提供一個apply)若爲函數則直接調用,將compiler實例做爲參數傳入,方便插件調用這次構建提供的Webpack API並監聽後續的全部事件Hook。
      if (typeof plugin === 'function') {
        plugin.call(compiler, compiler)
      } else {
        plugin.apply(compiler)
      }
    }
  }
  // 應用默認的Webpack配置
  applyWebpackOptionsDefaults(options)
  // 隨即以後,觸發一些Hook
  compiler.hooks.environment.call()
  compiler.hooks.afterEnvironment.call()
  // 內置的Plugin的引入,對webpack options進行初始化
  new WebpackOptionsApply().process(options, compiler)
  compiler.hooks.initialize.call()
  return compiler
}

編譯階段

  1. 啓動編譯( run/watch 階段)

這裏有個小邏輯區分是不是 watch,若是是非 watch,則會正常執行一次 compiler.run()微信

若是是監聽文件(如:--watch)的模式,則會傳遞監聽的 watchOptions,生成 Watching 實例,每次變化都從新觸發回調。babel

若是不是監視模式就調用 Compiler 對象的 run 方法,befornRun->beforeCompile->compile->thisCompilation->compilation開始構建整個應用。

const { SyncHook, SyncBailHook, AsyncSeriesHook } = require('tapable')
class Compiler {
  constructor() {
    // 1. 定義生命週期鉤子
    this.hooks = Object.freeze({
      // ...只列舉幾個經常使用的常見鉤子,更多hook就不列舉了,有興趣看源碼
      done: new AsyncSeriesHook(['stats']), //一次編譯完成後執行,回調參數:stats
      beforeRun: new AsyncSeriesHook(['compiler']),
      runnew AsyncSeriesHook(['compiler']), //在編譯器開始讀取記錄前執行
      emit: new AsyncSeriesHook(['compilation']), //在生成文件到output目錄以前執行,回調參數:compilation
      afterEmit: new AsyncSeriesHook(['compilation']), //在生成文件到output目錄以後執行
      compilation: new SyncHook(['compilation''params']), //在一次compilation建立後執行插件
      beforeCompile: new AsyncSeriesHook(['params']),
      compilenew SyncHook(['params']), //在一個新的compilation建立以前執行
      make: new AsyncParallelHook(['compilation']), //完成一次編譯以前執行
      afterCompile: new AsyncSeriesHook(['compilation']),
      watchRunnew AsyncSeriesHook(['compiler']),
      failednew SyncHook(['error']),
      watchClosenew SyncHook([]),
      afterPluginsnew SyncHook(['compiler']),
      entryOptionnew SyncBailHook(['context''entry']),
    })
    // ...省略代碼
  }
  newCompilation() {
    // 建立Compilation對象回調compilation相關鉤子
    const compilation = new Compilation(this)
    //...一系列操做
    this.hooks.compilation.call(compilation, params) //compilation對象建立完成
    return compilation
  }
  watch() {
    //若是運行在watch模式則執行watch方法,不然執行run方法
    if (this.running) {
      return handler(new ConcurrentCompilationError())
    }
    this.running = true
    this.watchMode = true
    return new Watching(this, watchOptions, handler)
  }
  run(callback) {
    if (this.running) {
      return callback(new ConcurrentCompilationError())
    }
    this.running = true
    process.nextTick(() => {
      this.emitAssets(compilation, (err) => {
        if (err) {
          // 在編譯和輸出的流程中遇到異常時,會觸發 failed 事件
          this.hooks.failed.call(err)
        }
        if (compilation.hooks.needAdditionalPass.call()) {
          // ...
          // done:完成編譯
          this.hooks.done.callAsync(stats, (err) => {
            // 建立compilation對象以前
            this.compile(onCompiled)
          })
        }
        this.emitRecords((err) => {
          this.hooks.done.callAsync(stats, (err) => {})
        })
      })
    })

    this.hooks.beforeRun.callAsync(this, (err) => {
      this.hooks.run.callAsync(this, (err) => {
        this.readRecords((err) => {
          this.compile(onCompiled)
        })
      })
    })
  }
  compile(callback) {
    const params = this.newCompilationParams()
    this.hooks.beforeCompile.callAsync(params, (err) => {
      this.hooks.compile.call(params)
      //已完成complication的實例化
      const compilation = this.newCompilation(params)
      //觸發make事件並調用addEntry,找到入口js,進行下一步
      // make:表示一個新的complication建立完畢
      this.hooks.make.callAsync(compilation, (err) => {
        process.nextTick(() => {
          compilation.finish((err) => {
            // 封裝構建結果(seal),逐次對每一個module和chunk進行整理,每一個chunk對應一個入口文件
            compilation.seal((err) => {
              this.hooks.afterCompile.callAsync(compilation, (err) => {
                // 異步的事件須要在插件處理完任務時調用回調函數通知 Webpack 進入下一個流程,
                // 否則運行流程將會一直卡在這不往下執行
                return callback(null, compilation)
              })
            })
          })
        })
      })
    })
  }
  emitAssets() {}
}
  1. 編譯模塊:( make 階段)
  • 從 entry 入口配置文件出發, 調用全部配置的 Loader 對模塊進行處理,
  • 再找出該模塊依賴的模塊, 經過 acorn 庫生成模塊代碼的 AST 語法樹,造成依賴關係樹(每一個模塊被處理後的最終內容以及它們之間的依賴關係),
  • 根據語法樹分析這個模塊是否還有依賴的模塊,若是有則繼續循環每一個依賴;再遞歸本步驟直到全部入口依賴的文件都通過了對應的 loader 處理。
  • 解析結束後, webpack 會把全部模塊封裝在一個函數裏,並放入一個名爲 modules 的數組裏。
  • modules 傳入一個自執行函數中,自執行函數包含一個 installedModules 對象,已經執行的代碼模塊會保存在此對象中。
  • 最後自執行函數中加載函數( webpack__require)載入模塊。
class Compilation extends Tapable {
 constructor(compiler) {
  super();
  this.hooks = {};
  // ...
  this.compiler = compiler;
  // ...
  // 構建生成的資源
  this.chunks = [];
  this.chunkGroups = [];
  this.modules = [];
  this.additionalChunkAssets = [];
  this.assets = {};
  this.children = [];
  // ...
 }
 //
 buildModule(module, optional, origin, dependencies, thisCallback) {
  // ...
  // 調用module.build方法進行編譯代碼,build中 實際上是利用acorn編譯生成AST
  this.hooks.buildModule.call(module);
  module.build( /**param*/ );
 }
 // 將模塊添加到列表中,並編譯模塊
 _addModuleChain(context, dependency, onModule, callback) {
  // ...
  // moduleFactory.create建立模塊,這裏會先利用loader處理文件,而後生成模塊對象
  moduleFactory.create({
   contextInfo: {
    issuer"",
    compilerthis.compiler.name
   },
   context: context,
   dependencies: [dependency]
  }, (err, module) = > {
   const addModuleResult = this.addModule(module);
   module = addModuleResult.module;
   onModule(module);
   dependency.module = module;

   // ...
   // 調用buildModule編譯模塊
   this.buildModule(modulefalsenullnull, err = > {});
  });
 }
 // 添加入口模塊,開始編譯&構建
 addEntry(context, entry, name, callback) {
  // ...
  this._addModuleChain( // 調用_addModuleChain添加模塊
  context, entry, module = > {
   this.entries.push(module);
  },
  // ...
  );
 }
 seal(callback) {
  this.hooks.seal.call();

  // ...
  //完成了Chunk的構建和依賴、Chunk、module等各方面的優化
  const chunk = this.addChunk(name);
  const entrypoint = new Entrypoint(name);
  entrypoint.setRuntimeChunk(chunk);
  entrypoint.addOrigin(null, name, preparedEntrypoint.request);
  this.namedChunkGroups.set(name, entrypoint);
  this.entrypoints.set(name, entrypoint);
  this.chunkGroups.push(entrypoint);

  GraphHelpers.connectChunkGroupAndChunk(entrypoint, chunk);
  GraphHelpers.connectChunkAndModule(chunk, module);

  chunk.entryModule = module;
  chunk.name = name;

  // ...
  this.hooks.beforeHash.call();
  this.createHash();
  this.hooks.afterHash.call();
  this.hooks.beforeModuleAssets.call();
  this.createModuleAssets();
  if (this.hooks.shouldGenerateChunkAssets.call() !== false) {
   this.hooks.beforeChunkAssets.call();
   this.createChunkAssets();
  }
  // ...
 }

 createHash() {
  // ...
 }

 // 生成 assets 資源並 保存到 Compilation.assets 中 給webpack寫插件的時候會用到
 createModuleAssets() {
  for (let i = 0; i < this.modules.length; i++) {
   const module = this.modules[i];
   if (module.buildInfo.assets) {
    for (const assetName of Object.keys(module.buildInfo.assets)) {
     const fileName = this.getPath(assetName);
     this.assets[fileName] = module.buildInfo.assets[assetName];
     this.hooks.moduleAsset.call(module, fileName);
    }
   }
  }
 }

 createChunkAssets() {
  asyncLib.forEach(
  this.chunks, (chunk, callback) = > {
   // manifest是數組結構,每一個manifest元素都提供了 `render` 方法,提供後續的源碼字符串生成服務。至於render方法什麼時候初始化的,在`./lib/MainTemplate.js`中
   let manifest = this.getRenderManifest()
   asyncLib.forEach(
   manifest, (fileManifest, callback) = > {...
    source = fileManifest.render()
    this.emitAsset(file, source, assetInfo)
   }, callback)
  }, callback)
 }
}
class SingleEntryPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(
      'SingleEntryPlugin',
      (compilation, { normalModuleFactory }) => {
        compilation.dependencyFactories.set(
          SingleEntryDependency,
          normalModuleFactory
        )
      }
    )

    compiler.hooks.make.tapAsync(
      'SingleEntryPlugin',
      (compilation, callback) => {
        const { entry, name, context } = this

        const dep = SingleEntryPlugin.createDependency(entry, name)
        compilation.addEntry(context, dep, name, callback)
      }
    )
  }

  static createDependency(entry, name) {
    const dep = new SingleEntryDependency(entry)
    dep.loc = { name }
    return dep
  }
}

歸納一下 make 階段單入口打包的流程,大體爲 4 步驟

  1. 執行 SingleEntryPlugin(單入口調用 SingleEntryPlugin,多入口調用 MultiEntryPlugin,異步調用 DynamicEntryPlugin), EntryPlugin 方法中調用了 Compilation.addEntry 方法,添加入口模塊,開始編譯&構建
  2. addEntry 中調用 _addModuleChain,將模塊添加到依賴列表中,並編譯模塊
  3. 而後在 buildModule 方法中,調用了 NormalModule.build,建立模塊之時,會調用 runLoaders,執行 Loader,利用 acorn 編譯生成 AST
  4. 分析文件的依賴關係逐個拉取依賴模塊並重覆上述過程,最後將全部模塊中的 require 語法替換成 webpack_require 來模擬模塊化操做。
從源碼的角度,思考一下, loader 爲何是自右向左執行的,loader 中有 pitch 也會從右到左執行的麼?

runLoaders 方法調用 iteratePitchingLoaders 去遞歸查找執行有 pich 屬性的 loader ;若存在多個 pitch 屬性的 loader 則依次執行全部帶 pitch 屬性的 loader ,執行完後逆向執行全部帶 pitch 屬性的 normalnormal loader 後返回 result,沒有 pitch 屬性的 loader 就不會再執行;若 loaders 中沒有 pitch 屬性的 loader 則逆向執行 loader;執行正常 loader 是在 iterateNormalLoaders 方法完成的,處理完全部 loader 後返回 result

出自文章你真的掌握了 loader 麼?- loader 十問(https://juejin.im/post/5bc1a73df265da0a8d36b74f)

Compiler 和 Compilation 的區別

webpack 打包離不開 CompilerCompilation,它們兩個分工明確,理解它們是咱們理清 webpack 構建流程重要的一步。

Compiler 負責監聽文件和啓動編譯 它能夠讀取到 webpack 的 config 信息,整個 Webpack 從啓動到關閉的生命週期,通常只有一個 Compiler 實例,整個生命週期裏暴露了不少方法,常見的 run,make,compile,finish,seal,emit 等,咱們寫的插件就是做用在這些暴露方法的 hook 上

Compilation 負責構建編譯。每一次編譯(文件只要發生變化,)就會生成一個 Compilation 實例,Compilation 能夠讀取到當前的模塊資源,編譯生成資源,變化的文件,以及依賴跟蹤等狀態信息。同時也提供不少事件回調給插件進行拓展。

完成編譯

  1. 輸出資源:(seal 階段)

在編譯完成後,調用 compilation.seal 方法封閉,生成資源,這些資源保存在 compilation.assets, compilation.chunk,而後便會調用 emit 鉤子,根據 webpack config 文件的 output 配置的 path 屬性,將文件輸出到指定的 path.

  1. 輸出完成: done/failed 階段

done 成功完成一次完成的編譯和輸出流程。failed 編譯失敗,能夠在本事件中獲取到具體的錯誤緣由 在肯定好輸出內容後, 根據配置肯定輸出的路徑和文件名, 把文件內容寫入到文件系統。

emitAssets(compilation, callback) {
    const emitFiles = (err) => {
      //...省略一系列代碼
      // afterEmit:文件已經寫入磁盤完成
      this.hooks.afterEmit.callAsync(compilation, (err) => {
        if (err) return callback(err)
        return callback()
      })
    }

    // emit 事件發生時,能夠讀取到最終輸出的資源、代碼塊、模塊及其依賴,並進行修改(這是最後一次修改最終文件的機會)
    this.hooks.emit.callAsync(compilation, (err) => {
      if (err) return callback(err)
      outputPath = compilation.getPath(this.outputPath, {})
      mkdirp(this.outputFileSystem, outputPath, emitFiles)
    })
  }

而後,咱們來看一下 webpack 打包好的代碼是什麼樣子的。

webpack 輸出文件代碼分析

未壓縮的 bundle.js 文件結構通常以下:

;(function (modules{
  // webpackBootstrap
  // 緩存 __webpack_require__ 函數加載過的模塊,提高性能,
  var installedModules = {}

  /**
   * Webpack 加載函數,用來加載 webpack 定義的模塊
   * @param {String} moduleId 模塊 ID,通常爲模塊的源碼路徑,如 "./src/index.js"
   * @returns {Object} exports 導出對象
   */

  function __webpack_require__(moduleId{
    // 重複加載則利用緩存,有則直接從緩存中取得
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports
    }
    // 若是是第一次加載,則初始化模塊對象,並緩存
    var module = (installedModules[moduleId] = {
      i: moduleId,
      lfalse,
      exports: {},
    })

    // 把要加載的模塊內容,掛載到module.exports上
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    )
    module.l = true // 標記爲已加載

    // 返回加載的模塊,直接調用便可
    return module.exports
  }
  // 在 __webpack_require__ 函數對象上掛載一些變量及函數 ...
  __webpack_require__.m = modules
  __webpack_require__.c = installedModules
  __webpack_require__.d = function (exports, name, getter{
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {
        enumerabletrue,
        get: getter,
      })
    }
  }
  __webpack_require__.n = function (module{
    var getter =
      module && module.__esModule
        ? function getDefault({
            return module['default']
          }
        : function getModuleExports({
            return module
          }
    __webpack_require__.d(getter, 'a', getter)
    return getter
  }
  // __webpack_require__對象下的r函數
  // 在module.exports上定義__esModule爲true,代表是一個模塊對象
  __webpack_require__.r = function (exports{
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, {
        value'Module',
      })
    }
    Object.defineProperty(exports, '__esModule', {
      valuetrue,
    })
  }
  __webpack_require__.o = function (object, property{
    return Object.prototype.hasOwnProperty.call(object, property)
  }
  //  從入口文件開始執行
  return __webpack_require__((__webpack_require__.s = './src/index.js'))
})({
  './src/index.js'function (
    module,
    __webpack_exports__,
    __webpack_require__
  
{
    'use strict'
    eval(
      '__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./moduleA */ "./src/moduleA.js");\n/* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_moduleA__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var _moduleB__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./moduleB */ "./src/moduleB.js");\n/* harmony import */ var _moduleB__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_moduleB__WEBPACK_IMPORTED_MODULE_1__);\n\n\n\n//# sourceURL=webpack:///./src/index.js?'
    )
  },
  './src/moduleA.js'function (module, exports{
    eval('console.log("moduleA")\n\n//# sourceURL=webpack:///./src/moduleA.js?')
  },
  './src/moduleB.js'function (module, exports{
    // 代碼字符串能夠經過eval 函數運行
    eval('console.log("moduleB")\n\n//# sourceURL=webpack:///./src/moduleB.js?')
  },
})

上述代碼的實現了⼀個 webpack_require 來實現⾃⼰的模塊化把代碼都緩存在 installedModules ⾥,代碼⽂件以對象傳遞進來,key 是路徑,value 是包裹的代碼字符串,而且代碼內部的 require,都被替換成了 webpack_require,代碼字符串能夠經過 eval 函數去執行。

bundle.js 能直接運行在瀏覽器中的緣由在於輸出的文件中經過 webpack_require 函數定義了一個能夠在瀏覽器中執行的加載函數來模擬 Node.js 中的 require 語句。

總結一下,生成的 bundle.js只包含一個當即調用函數(IIFE),這個函數會接受一個對象爲參數,它其實主要作了兩件事:

  1. 定義一個模塊加載函數 webpack_require。

  2. 使用加載函數加載入口模塊 "./src/index.js",從入口文件開始遞歸解析依賴,在解析的過程當中,分別對不一樣的模塊進行處理,返回模塊的 exports

因此咱們只須要實現 2 個功能就能夠實現一個簡單的仿 webpack 打包 js 的編譯工具

  1. 從入口開始遞歸分析依賴
  2. 藉助依賴圖譜來生成真正能在瀏覽器上運行的代碼

實現一個簡單的 webpack

接下來從 0 開始實踐一個 Webpack 的雛形,可以讓你們更加深刻了解 Webpack

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser'//解析成ast
const traverse = require('@babel/traverse').default //遍歷ast
const { transformFromAst } = require('@babel/core'//ES6轉換ES5
module.exports = class Webpack {
  constructor(options) {
    const { entry, output } = options
    this.entry = entry
    this.output = output
    this.modulesArr = []
  }
  run() {
    const info = this.build(this.entry)
    this.modulesArr.push(info)
    for (let i = 0; i < this.modulesArr.length; i++) {
      // 判斷有依賴對象,遞歸解析全部依賴項
      const item = this.modulesArr[i]
      const { dependencies } = item
      if (dependencies) {
        for (let j in dependencies) {
          this.modulesArr.push(this.build(dependencies[j]))
        }
      }
    }
    //數組結構轉換
    const obj = {}
    this.modulesArr.forEach((item) => {
      obj[item.entryFile] = {
        dependencies: item.dependencies,
        code: item.code,
      }
    })
    this.emitFile(obj)
  }
  build(entryFile) {
    const conts = fs.readFileSync(entryFile, 'utf-8')
    const ast = parser.parse(conts, {
      sourceType'module',
    })
    //  console.log(ast)
    // 遍歷全部的 import 模塊,存入dependecies
    const dependencies = {}
    traverse(ast, {
      //  類型爲 ImportDeclaration 的 AST 節點,
      // 其實就是咱們的 import xxx from xxxx
      ImportDeclaration({ node }) {
        const newPath =
          './' + path.join(path.dirname(entryFile), node.source.value)
        dependencies[node.source.value] = newPath
        // console.log(dependencies)
      },
    })
    // 將轉化後 ast 的代碼從新轉化成代碼
    // 並經過配置 @babel/preset-env 預置插件編譯成 es5
    const { code } = transformFromAst(ast, null, {
      presets: ['@babel/preset-env'],
    })
    return {
      entryFile,
      dependencies,
      code,
    }
  }
  emitFile(code) {
    //生成bundle.js
    const filePath = path.join(this.output.path, this.output.filename)
    const newCode = JSON.stringify(code)
    const bundle = `(function(modules){
           // moduleId 爲傳入的 filename ,即模塊的惟一標識符
            function require(moduleId){
                function localRequire(relativePath){
                   return require(modules[moduleId].dependencies[relativePath]) 
                }
                var exports = {};
                (function(require,exports,code){
                    eval(code)
                })(localRequire,exports,modules[moduleId].code)
                return exports;
            }
            require('${this.entry}')
        })(${newCode})`

    fs.writeFileSync(filePath, bundle, 'utf-8')
  }
}

調用

let path = require('path')
let { resolve } = path
let webpackConfig = require(path.resolve('webpack.config.js'))
let Webpack = require('./myWebpack.js')

const defaultConfig = {
  entry'src/index.js',
  output: {
    path: resolve(__dirname, '../dist'),
    filename'bundle.js',
  },
}
const config = {
  ...defaultConfig,
  ...webpackConfig,
}

const options = require('./webpack.config')
new Webpack(options).run()

輸入到瀏覽器看一下執行結果

參考:

  1. 快狗打車:實現一個簡單的 webpack
  2. tapable 詳解+ webpack 流程
  3. 本文調試以及打斷點用到的源碼

本文分享自微信公衆號 - 前端迷(love_frontend)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索