深刻 webpack 打包后的 js 世界

前言

在现代主流的前端项目开发中,几乎总能找到 webpack 的影子,它彷佛已经成了现今前端开发中不可或缺的一部分。css

下图是 webpack 官网首页,它生动形象的展示了 webpack 的核心功能:将一堆依赖关系复杂的模块打包成整齐有序的静态资源。前端

webpack

webpack 的出现加上现成脚手架的支持,让咱们能够集中精力在项目开发上,而无需过多关注打包过程和结果。node

但是,你是否好奇过,webpack 打包后的 js 代码是如何作到有序加载执行的?webpack

回忆一下,咱们在使用 webpack 的项目中,任何资源均可以被视做模块(只要有对应的 loader 支持解析),而这时模块(module)的载体是文件。但在项目打包后,模块的载体变成了函数,也被称为模块函数(module function),而文件则成了 chunk(块)的载体。所谓 chunk,是 webpack 中的一个概念,是由若干个模块组成的一个集合,一个 chunk 每每对应着一个文件。web

实际上,要回答上面提出的问题,就要搞清楚打包后模块与模块,chunk 与 chunk,以及模块与 chunk 之间的关系。因此,下面咱们将从 模块 和 chunk 这两个维度展开这篇文章。npm

模块

因为模块是资源加载中的最小单位,因此咱们从最简单的模块加载开始。json

下面是一个基础的 webpack4 配置文件。bootstrap

// webpack.config.js
const path = require('path')

module.exports = {
  mode: 'production',
  entry: {
    main: './src/main.js'
  },
  output: {
    filename: '[name].js',
    chunkFilename: '[name].[contenthash:8].js',
    path: path.resolve(__dirname, 'dist')
  },
  optimization: {
    // 为了方便阅读理解打包后的代码,关闭代码压缩和模块合并
    minimize: false,
    concatenateModules: false
  }
}
复制代码

在执行构建命令 npm run build 后,项目中会生成一个 dist 文件夹,里面包含了一个 main.js 文件,下面是文件中的内容。(本文全部的示例代码为了聚焦重点,忽略掉了部分暂时无关的代码,避免加剧读者阅读负担)数组

(function(modules) { // webpackBootstrap
    // 用于缓存已加载模块的地方
    var installedModules = {};

    // 用于加载模块的 require 函数
    function __webpack_require__(moduleId) { ... }

    // 加载入口模块(假设入口模块 id 为0)
    return __webpack_require__(0)
})([...])
复制代码

上面代码的实质是一个当即调用函数表达式(IIFE),其中的函数部分叫作 webpackBootstrap,传入的实参是一个包含模块函数的数组或对象。promise

webpackBootstrap

下面是 bootstrap 的英文含义:

A technique of loading a program into a computer by means of a few initial instructions which enable the introduction of the rest of the program from an input device.

翻译过来就是一种经过一些初始指令将程序加载到计算机中的技术,该初始指令使得可以从输入设备引入程序的其他部分。

若是仍是不太理解的话,能够简单的将其理解成控制中心,负责一切事物的启动、调度、执行等。

在 webpackBootstrap 中,定义了一个模块缓存对象(installedModules)用于存放已加载模块,以及一个模块加载函数(__webpack_require__)用于获取对应 id 的模块,最后加载入口模块以启动整个程序。

下面咱们重点来说一下模块的加载。

// 加载模块
function __webpack_require__(moduleId) {
  // 检查缓存中是否有该模块,如有,则直接返回
  if (installedModules[moduleId]) {
    return installedModules[moduleId].exports
  }

  // 初始化一个新模块,而且保存到缓存中
  var module = installedModules[moduleId] = {
    i: moduleId, // 模块名
    l: false, // 布尔值,表示该模块是否加载完毕
    exports: {} // 模块的输出对象,包含了模块输出的各个接口
  }

  // 执行模块函数,并传入三个实参:模块自己、模块的输出对象、加载函数,同时定义 this 值为模块的输出对象
  modules[moduleId].call(
    module.exports,
    module,
    module.exports,
    __webpack_require__
  )

  // 标记模块为已加载状态
  module.l = true

  // 返回模块的输出对象
  return module.exports
}
复制代码

上面的代码说明,编译后模块的加载遵循 CommonJS 规范。不过,CommonJS 模块规范不是同步加载模块,不适用于浏览器端吗?实际上,这是由于 webpack 编译后的代码确保了在对模块进行加载时,模块已经从服务器下载好了,所以并无同步请求致使的阻塞问题。至于这个问题具体是如何解决的,会在下面的 chunk 章节做出解释。

下面是模块加载的流程图。

模块加载流程

不过,这里有几点值得注意:

  • 同一个模块若是加载屡次,将只执行一次,因此须要对加载过的模块进行缓存。
  • 在初始化新模块后,新模块被当即保存到缓存中,而不是在模块加载完成后。这实际上是为了解决模块间循环加载(circular dependency)问题,即 a 模块依赖 b 模块,b 模块依赖 a 模块。这样一来,未执行完毕的模块被再次加载时,会在检查缓存时直接返回模块的输出对象(该输出对象可能并不包含模块所有的输出接口),以免无限循环。
  • 在 CommonJS 模块中,顶层的 this 值为 module.exports,所以利用 call 函数定义模块函数的 this 值为 module.exports。可是在 ES6 模块中,顶层的 this 为 undefined,因此在编译时 this 就被转换成了 undefined。

模块函数

那么执行模块函数时究竟作了什么呢?简单来讲,就是将被加载模块的输出接口添加到输出对象上。

下面咱们经过一个简单的例子来看看模块函数(因为 webpack 官方推荐使用 ES6 模块语法,所以示例中使用 ES6 中的 import/export)。

// src/lib.js
export let counter = 0

export function plusOne() {
  counter++
}

// src/main.js(入口模块)
import { counter, plusOne } from './lib'

console.log(counter)

plusOne()
console.log(counter)
复制代码

下面是 webpack 打包编译后的模块函数。

function(modules) {
  ...
  // 步骤1:加载入口模块
  return __webpack_require__(1)
})(
  [
    /* moduleId: 0 */
    function(module, __webpack_exports__, __webpack_require__) {
      // ES6模块默认采用严格模式
 'use strict'

      // 步骤1.1.1:在输出对象上定义输出的各个接口
      __webpack_require__.d(__webpack_exports__, 'a', function() {
        return counter
      })
      __webpack_require__.d(__webpack_exports__, 'b', function() {
        return plusOne
      })

      // 步骤1.1.2:声明定义输出接口的值
      let counter = 0

      function plusOne() {
        counter++
      }
    },
    /* moduleId: 1 */
    function(module, __webpack_exports__, __webpack_require__) {
 'use strict'
      // 步骤1.1:加载lib.js模块,并返回其输出对象
      var _lib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(0)
      // _lib__WEBPACK_IMPORTED_MODULE_0__ = {
      // get a() { return couter },
      // get b() { return pluseOne }
      // }

      // 步骤1.2:调用输出对象上的输出接口
      console.log(_lib__WEBPACK_IMPORTED_MODULE_0__[/* counter */ 'a'])

      Object(_lib__WEBPACK_IMPORTED_MODULE_0__[/* plusOne */ 'b'])()
      console.log(_lib__WEBPACK_IMPORTED_MODULE_0__[/* counter */ 'a'])
    }
  ]
)

复制代码

上面代码中,ES6 模块文件被编译成了 CommonJS 规范的模块函数。为了保持 ES6 模块语法的特性,编译后的代码变得有些晦涩难懂。其中有几点比较费解:

  1. __webpack_require__.d 函数是用来干什么的?

    这个函数是为了在输出对象上定义输出的各个接口。但是简单的对象属性赋值不就能够完成这个任务吗?这是由于ES6 模块输出的是值的只读引用

    下面是 __webpack_require__.d 的实现。

    __webpack_require__.d = function(exports, name, getter) {
      // __webpack_require__.o 是用于判断输出对象上是否已存在同名的输出接口
      if (!__webpack_require__.o(exports, name)) {
        Object.defineProperty(exports, name, { enumerable: true, get: getter })
      }
    }
    复制代码

    上面的代码代表在输出 ES6 模块的接口时,会使用 Object.defineProperty 方法来定义输出对象上的属性,并且只定义属性的 getter(取值器函数),以此实现了输出接口为只读。再经过属性的 getter 配合闭包实现了输出接口为值的引用。

  2. 为何统一在模块函数顶部定义输出接口(除 export default 的一些特殊场景之外,如 export default 1 这样没有明确指定输出接口名的)?

    这是由于 ES6 模块是编译时输出接口,相比之下,CommonJS 模块是运行时加载。这二者间的区别在模块间循环加载问题中会获得体现。所以为了模拟 ES6 模块的这个特性,须要在模块加载所依赖的模块或执行其余操做前,先定义输出接口名。

  3. 为何在消费 lib 模块的输出接口时,须要每次都从输出对象上取(如步骤1.2中消费 couter 值),而不像原代码中输出接口是独立的(如原代码中的 couter 变量)?

    这是因为输出对象上的属性其实是一个 getter 函数,若是将该属性值取出单独声明一个变量,便失去闭包的效果,没法追踪被加载模块中输出接口值的变更,也就失去了输出接口为值的引用这一 ES6 模块的特性。以上面示例代码举例,正常状况下控制台依次输出0和1,但若是将编译后的输出接口值赋予一个新变量,控制台则会输出两次0。

chunk (块)

若是一个项目的代码体积变大,那么将全部 js 代码打包到一个文件中必然会遇到性能瓶颈,致使资源加载时间过长。这时候,webpack 的 split chunks 技术就派上了用场。能够根据不一样的分包优化策略,将模块拆分到不一样的 chunk 文件中。

异步 chunk(async chunk)

对于一些访问频率较低的路由或使用频率较低的组件能够经过懒加载拆分为异步 chunk。

异步 chunk 能够经过调用 import() 方法动态加载模块获得。下面咱们改造一下 main.js 文件,以懒加载 lib 模块。

// src/main.js
import('./lib').then((lib) => {
  console.log(lib.counter)

  lib.plusOne()
  console.log(lib.counter)
})
复制代码

下面是从新构建打包后的 main.js 文件(只展现了新增的和发生变动的代码)。

// dist/main.js
(function(modules) {
  // chunk 下载完毕后执行的函数
  function webpackJsonpCallback(data) { ... }

  // 用于标记各个 chunk 加载状态的对象
  // undefined:chunk 未加载
  // null:chunk preloaded/prefetched
  // Promise:chunk 正在加载
  // 0:chunk 已加载
  var installedChunks = {
    0: 0
  }

  // 获取 chunk 的请求地址(url),包含了 chunk 名及 chunk 哈希
  function jsonpScriptSrc(chunkId) {
    return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"1":"3215c03a"}[chunkId] + ".js"
  }

  // 获取 chunk
  __webpack_require__.e = function requireEnsure(chunkId) { ... }

  // chunk 的公共路径(public path),即 webpack 配置中的 output.publicPath
  __webpack_require__.p = "";

  // 围绕 webpackJsonp 的一系列操做,会在下面作详细介绍
  var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
  var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
  jsonpArray.push = webpackJsonpCallback;
  jsonpArray = jsonpArray.slice();
  for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
  var parentJsonpFunction = oldJsonpFunction;

  return __webpack_require__(0);
})([
  /* 0 */
  (function(module, exports, __webpack_require__) {
    __webpack_require__.e(/* import() */ 1).then(__webpack_require__.bind(null, 1)).then((lib) => {
      console.log(lib.counter)

      lib.plusOne()
      console.log(lib.counter)
    })
  })
])
复制代码

__webpack_require__.e

上面的代码中,入口模块中的 import('./lib') 被编译为了 __webpack_require__.e(1).then(__webpack_require__.bind(null, 1)), 这实际上等价于下面的代码。

__webpack_require__.e(1)
  .then(function() {
    return __webpack_require__(1)
  })
复制代码

上面的代码由两部分组成,前半段的 __webpack_require__.e(1) 是用来异步加载 chunk 的,后半段传入 then 方法中的回调函数是用来同步加载 lib 模块的。

这就解决了 CommonJS 规范中同步加载模块会在浏览器端致使的执行堵塞问题。

// 获取 chunk
__webpack_require__.e = function requireEnsure(chunkId) {
  var promises = [];

  // 利用 JSONP 下载 js chunk
  var installedChunkData = installedChunks[chunkId];
  // 0表明该 chunk 已加载完毕
  if(installedChunkData !== 0) {

    if(installedChunkData) { // chunk 正在加载中
      promises.push(installedChunkData[2]);
    } else {
      // 在 chunk 缓存中更新 chunk 状态为正在加载中,并缓存 resolve、reject、promise
      var promise = new Promise(function(resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      promises.push(installedChunkData[2] = promise);

      // 开始准备下载 chunk
      var script = document.createElement('script');
      var onScriptComplete;

      script.charset = 'utf-8';
      script.timeout = 120;
      if (__webpack_require__.nc) {
        script.setAttribute("nonce", __webpack_require__.nc);
      }
      script.src = jsonpScriptSrc(chunkId);

      // 在堆栈展开以前建立 error 以便稍后得到有用的堆栈跟踪
      var error = new Error();
      // chunk 下载完成后(成功或异常)的回调函数
      onScriptComplete = function (event) {
        // 防止 IE 浏览器下内存泄漏
        script.onerror = script.onload = null;
        clearTimeout(timeout);
        var chunk = installedChunks[chunkId];
        if(chunk !== 0) {
          if(chunk) {
            var errorType = event && (event.type === 'load' ? 'missing' : event.type);
            var realSrc = event && event.target && event.target.src;
            error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
            error.name = 'ChunkLoadError';
            error.type = errorType;
            error.request = realSrc;
            // reject(error)
            chunk[1](error);
          }
          installedChunks[chunkId] = undefined;
        }
      };
      // 处理请求超时
      var timeout = setTimeout(function(){
        onScriptComplete({ type: 'timeout', target: script });
      }, 120000);
      // 处理请求成功及异常
      script.onerror = script.onload = onScriptComplete;
      // 发起请求
      document.head.appendChild(script);
    }
  }
  return Promise.all(promises);
};
复制代码

下面是__webpack_require__.e的执行流程:

  1. 初始化 promises 变量,用于处理各个 chunk 的异步加载流程(虽然上面示例中只须要处理一个 js chunk,可是有些 js 模块依赖于 css 文件,所以在加载 js chunk 的同时也会加载其依赖的 css chunk,须要处理多个 chunk 的异步加载,因此该变量为一个数组)。
  2. 判断 js chunk 是否已加载,若是 chunk 已加载,直接跳到第6步,不然继续往下执行。
  3. 判断 chunk 是否正在加载,若是是,将 chunk 缓存(installedChunks)中该 chunk 保存的数组中的 promise 实例添加到 promises 数组中,而后跳到第6步,不然继续往下执行。
  4. 初始化一个 promise 实例用于处理 chunk 的异步加载流程,而且将由 Promise 的 resolve 和 reject 函数以及 promise 实例自己组成的数组(即 [resolve, reject, promise])保存到 chunk 缓存中,再将 promise 实例添加到 promises 数组中。
  5. 开始准备下载 chunk,包括建立 script 标签,设置 script 的 src、timeout 等一些属性,处理 script 的请求成功、失败、超时事件,最后添加 script 到 document 完成请求的发送。
  6. 执行并返回 Promise.all(promises),等全部异步 chunks 都加载成功后,再触发 then 方法中的回调函数(即加载 chunk 中包含的模块)。

或许有人会感到困惑,为何没有在 onScriptComplete 中 chunk === 0 时执行 resolve 函数?就像下面这样:

onScriptComplete = function (event) {
  ...
  var chunk = installedChunks[chunkId];
  if(chunk !== 0) {
    if(chunk) {
      ...
      // reject(error)
      chunk[1](error);
    }
    installedChunks[chunkId] = undefined;
  } else { // chunk === 0
    // resolve()
    chunk[0]()
  }
};
复制代码

这个问题的实质是 chunk 的异步加载流程何时才算结束?是否 chunk 下载完成后就算结束了?实际上,js chunk 的加载包含两个部分:下载 chunk 文件和执行 chunk 代码,只有当二者都完成后,该 chunk 才算加载完成。所以,resolve 被保存到了 chunk 缓存中,待 chunk 代码执行完毕后再执行 resolve 函数,结束掉异步加载流程。虽然 script 的 load 事件是在其下载并执行完毕后才触发,可是 load 事件只关心下载自己,即便 script 在执行过程当中抛出异常,依然会触发 load 事件。

webpackJsonpCallback

当 js chunk 下载成功后,就会开始执行代码,下面是 lib.js 模块打包获得的 chunk。

// dist/1.3215c03a.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],[
  /* 0 */,
  /* 1 */
  (function(module, __webpack_exports__, __webpack_require__) {
 "use strict";
    __webpack_require__.d(__webpack_exports__, "counter", function() { return counter; });
    __webpack_require__.d(__webpack_exports__, "plusOne", function() { return plusOne; });
    let counter = 0

    function plusOne() {
      counter++
    }

  })
]]);
复制代码

上面的代码看上去很简单,只有两步操做,一个是初始化 window["webpackJsonp"] 为数组(若以前未被初始化), 另外一个是经过 push 操做将一个数组添加到 window["webpackJsonp"] 数组中(表达并不严谨,详情见下文)。其中做为实参的数组又由两个数组构成,第一个数组是 chunkId 的集合(正常状况下,该数组只包含当前 chunkId。但在分包策略有误的状况下,该数组可能包含多个 chunkId),第二个数组则是模块函数的集合。

可是,原生的 push 操做只能简单的将 chunk 中的数据添加到数组里。那 webpack 到底是在哪里对数据作的处理?又是如何作的处理?

若是还有印象的话,在上面的 webpackBootstrap 中有一段围绕 window["webpackJsonp"] 数组的操做。

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
// 将 window["webpackJsonp"].push 方法替换为 webpackJsonpCallback 函数
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
// 对以前已加载的所有初始 chunk 中的数据调用 webpackJsonpCallback
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
// 将 window["webpackJsonp"] 数组原生的 push 方法赋给 parentJsonpFunction 变量
var parentJsonpFunction = oldJsonpFunction;
复制代码

从上面代码能够看出,全部的 chunks 中的数据(除 webpackBootstrap 所在的 chunk 之外)都是经过 JSONP 的形式(即调用 webpackJsonpCallback)加载进来并作处理的,但在 webpackBootstrap 所在的 chunk 未加载好的以前,webpackJsonpCallback 还未被声明定义,所以便将数据都先暂时保存在 window["webpackJsonp"] 数组里,待其加载好以后,先将 window["webpackJsonp"] 数组的 push 方法替换为 webpackJsonpCallback 函数(这样一来,以后加载的 chunk 虽然调用的是 push 方法,但其实是直接调用 webpackJsonpCallback 函数处理数据),再将先前保存在 window["webpackJsonp"] 数组里的数据依次调用 webpackJsonpCallback。

function webpackJsonpCallback(data) {
  var chunkIds = data[0];
  var moreModules = data[1];

  var moduleId, chunkId, i = 0, resolves = [];
  // 取出各个异步 chunk 所对应的 promise 的 resolve 函数,并在 chunk 缓存中标记 chunk 状态为已加载
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if(installedChunks[chunkId]) {
      resolves.push(installedChunks[chunkId][0]);
    }
    installedChunks[chunkId] = 0;
  }
  // 将 chunk 中包含的模块都添加到 webpackBootstrap 的 modules 对象中
  for(moduleId in moreModules) {
    if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
      modules[moduleId] = moreModules[moduleId];
    }
  }
  // 利用 window["webpackJsonp"] 数组原生的 push 方法将 chunk 中的数据添加到 window["webpackJsonp"] 中
  if(parentJsonpFunction) parentJsonpFunction(data);

  // 异步 chunks 加载成功,执行 resolve 函数来 fulfill 各个 chunk 对应的 promise,触发 then 中的回调函数
  while(resolves.length) {
    resolves.shift()();
  }
};
复制代码

webpackJsonpCallback 函数主要对 chunk 中的数据作了两个处理:缓存、结束异步加载流程。

缓存包含两个层面:一个是对 chunk 加载状态的缓存,以免对同一 chunk 发送屡次请求,另外一个是对模块函数的缓存,以便后期对模块的加载。

结束 chunk 的异步加载流程,实际就是执行 chunk 缓存中的 resolve 函数。

初始 chunk(initial chunk)

对于网站初始阶段须要加载的模块,能够根据模块的体积大小、共用率、更新频率,拆分为核心基础类库、UI 组件库、业务代码等多个初始 chunk。

为了获得多个初始 chunk,调整一下 main.js 文件和 webpack.config.js 配置。

// src/main.js
import * as _ from 'lodash'

const arr = [1, 2]
console.log(_.concat(arr, 3, [4]))

// webpack.config.js(基于上面的 webpack 配置)
module.exports = {
  ...
  optimization: {
    ...
    splitChunks: {
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10,
        },
      }
    }
  }
}
复制代码

上面代码中,main.js 模块依赖了 lodash 库,并将 lodash 库拆分到一个单独的 chunk 中,因此在 webpack 配置的 optimization 对象中增长了 splitChunks 配置,用于将 lodash 库拆分到名为 vendors 的 chunk 中。

下面是打包后 webpackBootstrap 中增长的代码。

(function(modules) { // webpackBootstrap
  function webpackJsonpCallback(data) {
    ...
    var executeModules = data[2];
    // 若是加载的 chunk 中有入口模块,则将其添加到 deferredModules 数组
    deferredModules.push.apply(deferredModules, executeModules || []);

    return checkDeferredModules();
  }

  // 检查入口模块所依赖的 chunk 是否加载完成,若是是,则加载入口模块,不然不执行任何操做
  function checkDeferredModules() {
    var result;
    // 遍历全部入口模块
    for(var i = 0; i < deferredModules.length; i++) {
      var deferredModule = deferredModules[i];
      var fulfilled = true;
      // 检查入口模块所依赖的所有 chunk 是否加载完成
      for(var j = 1; j < deferredModule.length; j++) {
        var depId = deferredModule[j];
        if(installedChunks[depId] !== 0) fulfilled = false;
      }
      // 若是入口模块依赖的所有 chunk 都加载完成,则加载入口模块
      if(fulfilled) {
        deferredModules.splice(i--, 1);
        result = __webpack_require__(deferredModule[0]);
      }
    }
    return result;
  }

  var deferredModules = [];

  // 将入口模块添加到 deferredModules 数组
  // 数组中第一个元素为入口模块 id,后面的元素都是入口模块依赖的初始 chunk 的 id
  deferredModules.push([1,1]);

  return checkDeferredModules();
})(...)
复制代码

原来只有一个初始 chunk 时, chunk 中包含了初始阶段所需的所有模块,因此当其下载好后就能够直接加载入口模块。但当模块被拆分到多个初始 chunk 中的时候,必须得等所有初始 chunk 都加载完成,所有初始阶段所需的模块都准备好后,才能够开始加载入口模块。所以,惟一不一样的是入口模块的加载时机被 defer(延迟) 了。

因此在上面代码中,webpackBootstrap 函数和 webpackJsonpCallback 函数都在最后调用了 checkDeferredModules 函数,确保全部 chunk 在加载完成后都会检查是否有入口模块已经知足了要求(即其依赖的所有初始 chunk 都已加载完成),若是有入口模块知足了,则开始加载该入口模块。

小结

本文实际上就解答了一个问题:webpack 打包后的 js 代码是如何运行的?答案的核心有两点:模块的加载和 chunk 的加载。前者同步阻塞,后者异步非阻塞。当你清楚如何将二者和谐的配合在一块儿时,也就离完整的答案不远了。

相关文章
相关标签/搜索