Понять принцип горячего обновления webpack

Webpack

содержание

Что такое ХМР

HMRкоторыйHot Module ReplacementЭто означает, что когда вы изменяете код и сохраняете его,webpackКод будет переупакован, и измененные модули будут отправлены в браузер, а браузер заменит старый модуль новым, чтобы добиться частичного обновления страницы вместо общего обновления страницы. Далее мы поможем вам понять простые функции от использования до реализации версии простых функций.HMR.

Статья впервые опубликована в@careteen/webpack-hmr, укажите источник для перепечатки.

сцены, которые будут использоваться

scenario

Как показано выше, страница регистрации содержит用户名,密码,邮箱три обязательных поля ввода и одно提交кнопка при отладке邮箱Когда модуль меняет код, вся страница будет обновляться без какой-либо обработки, частые изменения кода отнимают много времени у вас на пополнение контента. ожидается сохранение用户名,密码, но только заменить邮箱этот модуль. Это требование требуетwebpack-dev-serverфункция горячего обновления модуля.

относительноlive reloadСхема обновления страницы в целом,HMRПреимущество в том, что он может сохранять состояние приложения и повышать эффективность разработки.

Настроить для использования HMR

настроить веб-пакет

сначала с помощьюwebpackПостроить проект

  • Инициализировать проект и импортировать зависимости
mkdir webpack-hmr && cd webpack-hmr
npm i -y
npm i -S webpack webpack-cli webpack-dev-server html-webpack-plugin
  • конфигурационный файлwebpack.config.js
const path = require('path')
const webpack = require('webpack')
const htmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development', // 开发模式不压缩代码,方便调试
  entry: './src/index.js', // 入口文件
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js'
  },
  devServer: {
    contentBase: path.join(__dirname, 'dist')
  },
  plugins: [
    new htmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html'
    })
  ]
}
  • новыйsrc/index.htmlфайл шаблона
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Webpack Hot Module Replacement</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
  • новыйsrc/index.jsВходной файл пишет простую логику
var root = document.getElementById('root')
function render () {
  root.innerHTML = require('./content.js')
}
render()
  • Новый файл зависимостиsrc/content.jsЭкспорт символов для индексных страниц рендеринга
var ret = 'Hello Webpack Hot Module Replacement'
module.exports = ret
// export default ret
  • настроитьpackage.json
  "scripts": {
    "dev": "webpack-dev-server",
    "build": "webpack"
  }
  • потомnpm run devначать проект

  • пройти черезnpm run buildУпакуйте и сгенерируйте статические ресурсы дляdistсодержание

Далее проанализируйтеdistфайлы в каталоге

Разобрать содержимое файла, упакованного webpack

  • Набор спецификаций commonjs, реализованных самим webpack.
  • Различать commonjs и esmodule

dist структура каталогов

.
├── index.html
└── main.js

вindex.htmlСодержание выглядит следующим образом

<!-- ... -->
<div id="root"></div>
<script type="text/javascript" src="main.js"></script></body>
<!-- ... -->

использоватьhtml-webpack-pluginПлагин передает файл записи и его зависимости черезscriptЗнакомство с этикеткой

первый справаmain.jsКонтент удаляет комментарии и нерелевантный контент для анализа

(function (modules) { // webpackBootstrap
  // ...
})
({
  "./src/content.js":
    (function (module, exports) {
      eval("var ret = 'Hello Webpack Hot Module Replacement'\n\nmodule.exports = ret\n// export default ret\n\n");
    }),
  "./src/index.js": (function (module, exports, __webpack_require__) {
    eval("var root = document.getElementById('root')\nfunction render () {\n  root.innerHTML = __webpack_require__(/*! ./content.js */ \"./src/content.js\")\n}\nrender()\n\n\n");
  })
});

Видно, что после упаковки webpack будет сгенерирована самовыполняющаяся функция, параметром которой является объект

"./src/content.js": (function (module, exports) {
  eval("...")
}

Ключ представляет собой относительный путь входного файла или зависимого файла относительно корневого каталога, и значение является функцией, которая используется.evalСимволы содержимого исполняемого файла.

  • Затем введите самовыполняющееся тело функции, видно, что в webpack реализован наборcommonjsТехнические характеристики
(function (modules) {
  // 模块缓存
  var installedModules = {};
  function __webpack_require__(moduleId) {
    // 判断是否有缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 没有缓存则创建一个模块对象并将其放入缓存
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false, // 是否已加载
      exports: {}
    };
    // 执行模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 将状态置为已加载
    module.l = true;
    // 返回模块对象
    return module.exports;
  }
  // ...
  // 加载入口文件
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})

если вышеcommonjsЕсли вас интересует спецификация, вы можете перейти к моей другой статьеРука об руку, чтобы помочь вам реализовать спецификацию commonjs

Приведенный выше код дан в основном для ознакомления с выходными файлами webpack, не бойтесь. На самом деле любая вещь, какой бы сложной она ни была, состоит из более мелких и простых вещей, разрежьте ее на части, чтобы познать ее и влюбиться в нее.

Настроить HMR

Далее настройте и почувствуйте удобство разработки, которую принесли горячие обновления.

webpack.config.jsнастроить

  // ...
  devServer: {
    hot: true
  }
  // ...

./src/index.jsнастроить

// ...
if (module.hot) {
  module.hot.accept(['./content.js'], () => {
    render()
  })
}

при изменении./content.jsКогда содержимое сохранено и сохранено, вы можете видеть, что страница не была обновлена, но содержимое было заменено.

Это имеет большое значение для повышения эффективности разработки. Далее разрежем его слой за слоем и поймем принцип его реализации.

Принцип ГМР

core

Как показано выше, правоServerконечное применениеwebpack-dev-serverДля запуска локальной службы внутренняя реализация в основном используетwebpack,express,websocket.

  • использоватьexpressЗапустите локальную службу, которая отвечает, когда браузер обращается к ресурсу.
  • Использование сервера и клиентаwebsocketдолгосрочная связь
  • webpackСлушайте изменения в исходных файлах, то есть срабатывайте, когда разработчик сохраняет файл.webpackперекомпилировать.
    • Каждая компиляция будет генерироватьhash值,已改动模块的json文件,已改动模块代码的js文件
    • После компиляции пройтиsocketОтправьте текущую скомпилированную версию клиентуhash戳
  • клиентаwebsocketОтслеживается наличие изменений в файлахhash戳, сравним с предыдущим
    • Если непротиворечиво, перейти к кешу
    • Если нет, пройдиajaxа такжеjsonpПолучить последние ресурсы с сервера
  • использовать内存文件系统Замена измененного содержимого для частичного обновления

Приведенное выше изображение является только общим видом, а следующее подробно проанализирует два аспекта сервера и клиента.

исходный код сервера отладки

core

Теперь вам нужно сосредоточиться только на серверной части в правой части рисунка выше, а левую часть можно пока игнорировать. Следующие шаги предназначены в основном для анализа подробных идей исходного кода сервера отладки, а также для указания конкретного местоположения кода.Если вам интересно, вы можете сначала установить точку останова в следующем коде, а затем наблюдать за изменениями. в данных. Вы также можете сначала пропустить этот шаг.

  1. запускатьwebpack-dev-serverсервер, адрес исходного кода@webpack-dev-server/webpack-dev-server.js#L173
  2. Создайте экземпляр веб-пакета, адрес исходного кода@webpack-dev-server/webpack-dev-server.js#L89
  3. Создать сервер сервера, адрес исходного кода@webpack-dev-server/webpack-dev-server.js#L107
  4. Добавить обратный вызов события done веб-пакета, адрес исходного кода@webpack-dev-server/Server.js#L122
  5. После завершения компиляции отправить сообщение клиенту, адрес исходного кода@webpack-dev-server/Server.js#L184
  6. Создать экспресс-приложение, адрес исходного кода@webpack-dev-server/Server.js#L123
  7. Установите файловую систему на файловую систему памяти, адрес исходного кода@webpack-dev-middleware/fs.js#L115
  8. Добавить промежуточное ПО webpack-dev-middleware, адрес исходного кода@webpack-dev-server/Server.js#L125
  9. Промежуточное ПО отвечает за возврат сгенерированного файла, адрес исходного кода@webpack-dev-middleware/middleware.js#L20
  10. Начать компиляцию веб-пакета, адрес исходного кода@webpack-dev-middleware/index.js#L51
  11. Создайте http-сервер и запустите службу, адрес исходного кода@webpack-dev-server/Server.js#L135
  12. Используйте sockjs для установки длинного соединения через веб-сокет между браузером и сервером, адрес исходного кода@webpack-dev-server/Server.js#L745
  13. Создать сервер сокетов, адрес исходного кода@webpack-dev-server/SockJSServer.js#L34

Простая реализация сервера

Выше приведены некоторые из основных моментов рабочего процесса dev-сервера, которые я получил с помощью отладки.абстракция в один файл.

Запустите сервер webpack-dev-server

сначала импортируйте все зависимости

const path = require('path') // 解析文件路径
const express = require('express') // 启动本地服务
const mime = require('mime') // 获取文件类型 实现一个静态服务器
const webpack = require('webpack') // 读取配置文件进行打包
const MemoryFileSystem = require('memory-fs') // 使用内存文件系统更快,文件生成在内存中而非真实文件
const config = require('./webpack.config') // 获取webpack配置文件

Создать экземпляр веб-пакета

const compiler = webpack(config)

компилятор представляет всю задачу компиляции веб-пакета, есть только один глобальный

Создать сервер

class Server {
  constructor(compiler) {
    this.compiler = compiler
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服务器已经在${port}端口上启动了`)
    })
  }
}
let server = new Server(compiler)
server.listen(8000)

Сзади сервис запускается через экспресс

Добавить обратный вызов события done в webpack

  constructor(compiler) {
    let sockets = []
    let lasthash
    compiler.hooks.done.tap('webpack-dev-server', (stats) => {
      lasthash = stats.hash
      // 每当新一个编译完成后都会向客户端发送消息
      sockets.forEach(socket => {
        socket.emit('hash', stats.hash) // 先向客户端发送最新的hash值
        socket.emit('ok') // 再向客户端发送一个ok
      })
    })
  }

webpackПосле компиляции предоставляется ряд функций-ловушек, чтобы плагин мог получить доступ к различным узлам своего жизненного цикла и изменить содержимое своего пакета.compiler.hooks.doneЭто последний узел, в котором плагин может изменять свое содержимое.

Компиляция завершена черезsocketОтправьте сообщение клиенту, чтобы отправить сгенерированные файлы для каждой компиляции.hash. Кроме того, если это горячее обновление, будут сгенерированы два файла патчей, которые описывают, какие чанки и модули изменились с последнего результата на этот.

использоватьlet sockets = []Массив для хранения значения каждой вкладки при открытии нескольких вкладокsocket实例.

Создать экспресс-приложение

let app = new express()

Установите файловую систему в файловую систему в памяти

let fs = new MemoryFileSystem()

использоватьMemoryFileSystemБудуcompilerВыходной файл упаковывается в память.

Добавить промежуточное ПО webpack-dev-middleware

  function middleware(req, res, next) {
    if (req.url === '/favicon.ico') {
      return res.sendStatus(404)
    }
    // /index.html   dist/index.html
    let filename = path.join(config.output.path, req.url.slice(1))
    let stat = fs.statSync(filename)
    if (stat.isFile()) { // 判断是否存在这个文件,如果在的话直接把这个读出来发给浏览器
      let content = fs.readFileSync(filename)
      let contentType = mime.getType(filename)
      res.setHeader('Content-Type', contentType)
      res.statusCode = res.statusCode || 200
      res.send(content)
    } else {
      return res.sendStatus(404)
    }
  }
  app.use(middleware)

После использования Express для запуска локальной службы разработки используйте промежуточное программное обеспечение для создания для него статического сервера и используйте файловую систему в памяти для хранения файлов в памяти после их чтения, повышения эффективности чтения и записи и, наконец, возврата сгенерированных файлов.

Начать компиляцию веб-пакета

  compiler.watch({}, err => {
    console.log('又一次编译任务成功完成了')
  })

Запустите компиляцию веб-пакета в режиме мониторинга и выполните обратный вызов, когда компиляция будет успешной.

Создайте http-сервер и запустите службу

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    // ...
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服务器已经在${port}端口上启动了`)
    })
  }

Используйте sockjs для установки длинного соединения через веб-сокет между браузером и сервером.

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    let io = require('socket.io')(this.server)
    io.on('connection', (socket) => {
      sockets.push(socket)
      socket.emit('hash', lastHash)
      socket.emit('ok')
    })
  }

Запустите сервер веб-сокетов, затем дождитесь прибытия соединения и сохраните его в пуле сокетов после получения соединения.

Когда есть изменения файла и перекомпиляция веб-пакета, нажмите на клиентhashа такжеokдва события

отладка на стороне сервера

Заинтересованные могут обратиться к вышеисходный код сервера отладкиПеренесите местоположение исходного кода и установите точки останова в режиме отладки браузера, чтобы увидеть значение каждого этапа.

node dev-server.js

Используя нашу собственную скомпилированнуюdev-server.jsЗапустив сервис, вы можете видеть, что страница может отображаться нормально, но горячее обновление еще не реализовано.

Далее будет проанализирован процесс реализации исходного кода клиента отладки.

отлаживать исходный код клиента

core

Теперь вам нужно только сосредоточиться на клиентской части на левой стороне рисунка выше, а правая сторона может быть проигнорирована на то время. Следующие шаги в основном для отладки анализа исходного кода клиента и его подробные идеи, а также предоставляют конкретное местоположение кода. Если вы заинтересованы, вы можете сначала найти следующий код, чтобы установить точку останова, а затем наблюдать за изменениями данных Отказ Вы также можете сначала пропустить этот шаг.

Подробное представление об анализе исходного кода клиента отладки

  1. Сторона webpack-dev-server/client будет прослушивать это хеш-сообщение, адрес исходного кода@webpack-dev-server/index.js#L54
  2. После того, как клиент получит сообщение ok, он выполнит метод reloadApp для обновления адреса исходного кода.index.js#L101
  3. В reloadApp будет оцениваться, поддерживается ли горячее обновление. Если оно поддерживается, генерируется событие webpackHotUpdate. Если не поддерживается, браузер обновляется напрямую. Адрес исходного кодаreloadApp.js#L7
  4. Событие webpackHotUpdate будет отслеживаться в webpack/hot/dev-server.js, адрес исходного кодаdev-server.js#L55
  5. В методе проверки будет вызываться метод module.hot.check, адрес исходного кодаdev-server.js#L13
  6. Манифест запроса HotModuleReplacement.runtime, исходный адресHotModuleReplacement.runtime.js#L180
  7. Это метод hotDownloadManifest, вызывающий JsonpMainTemplate.runtime, исходный адресJsonpMainTemplate.runtime.js#L23
  8. Вызовите метод hotDownloadUpdateChunk JsonpMainTemplate.runtime, чтобы получить последний код модуля через запрос JSONP, адрес исходного кодаJsonpMainTemplate.runtime.js#L14
  9. После получения патча JS будет вызван метод webpackHotUpdate JsonpMainTemplate.runtime.js.JsonpMainTemplate.runtime.js#L8
  10. Затем он вызовет метод hotAddUpdateChunk HotModuleReplacement.runtime.js для динамического обновления кода модуля, адреса исходного кодаHotModuleReplacement.runtime.js#L222
  11. Затем вызовите метод hotApply для горячего обновления, адрес исходного кодаHotModuleReplacement.runtime.js#L257,HotModuleReplacement.runtime.js#L278

Простая реализация клиента

Выше приведены некоторые из основных моментов рабочего процесса dev-сервера, которые я получил с помощью отладки.реферат в один файл.

Сторона webpack-dev-server/client будет прослушивать это хеш-сообщение.

Прежде чем разрабатывать функциональность на стороне клиента, вам необходимоsrc/index.htmlвведен вsocket.io

<script src="/socket.io/socket.io.js"></script>

Подключите разъем ниже и примите сообщение

let socket = io('/')
socket.on('connect', onConnected)
const onConnected = () => {
  console.log('客户端连接成功')
}
let hotCurrentHash // lastHash 上一次 hash值 
let currentHash // 这一次的hash值
socket.on('hash', (hash) => {
  currentHash = hash
})

Генерируется каждой компиляцией веб-пакета на стороне сервераhashкэшировать

После того, как клиент получит сообщение ok, он выполнит метод reloadApp для обновления.

socket.on('ok', () => {
  reloadApp(true)
})

Определите, поддерживается ли горячее обновление в reloadApp

// 当收到ok事件后,会重新刷新app
function reloadApp(hot) {
  if (hot) { // 如果hot为true 走热更新的逻辑
    hotEmitter.emit('webpackHotUpdate')
  } else { // 如果不支持热更新,则直接重新加载
    window.location.reload()
  }
}

В reloadApp будет оцениваться, поддерживается ли горячее обновление. Если оно поддерживается, будет сгенерировано событие webpackHotUpdate. Если оно не поддерживается, браузер будет обновлен напрямую.

Событие webpackHotUpdate отслеживается в файле webpack/hot/dev-server.js.

Во-первых, вам нужна публикация-подписка, чтобы привязывать события и запускать их в нужное время.

class Emitter {
  constructor() {
    this.listeners = {}
  }
  on(type, listener) {
    this.listeners[type] = listener
  }
  emit(type) {
    this.listeners[type] && this.listeners[type]()
  }
}
let hotEmitter = new Emitter()
hotEmitter.on('webpackHotUpdate', () => {
  if (!hotCurrentHash || hotCurrentHash == currentHash) {
    return hotCurrentHash = currentHash
  }
  hotCheck()
})

Он будет судить, был ли это первый вход на страницу и был ли обновлен код.

Вышеупомянутая публикация и подписка относительно просты и поддерживают только функцию сначала публикации, а затем подписки. Для некоторых более сложных сценариев может потребоваться подписка, а затем публикация, и вы можете переместить@careteen/event-emitter. Принцип реализации также довольно прост: необходимо поддерживать офлайн-стек событий для хранения событий, которые не были опубликованы и не подписаны, а все события могут быть извлечены и выполнены, когда на них подписаны.

Метод module.hot.check будет вызываться в методе проверки

function hotCheck() {
  hotDownloadManifest().then(update => {
    let chunkIds = Object.keys(update.c)
    chunkIds.forEach(chunkId => {
      hotDownloadUpdateChunk(chunkId)
    })
  })
}

Как упоминалось выше, webpack будет генерироваться каждый раз при компиляции.hash值,已改动模块的json文件,已改动模块代码的js文件,

использовать сначалаajaxпроситьManifestТо есть, какие модули и чанки были изменены сервером для этой компиляции относительно предыдущей компиляции.

затем пройтиjsonpПолучите код для этих модифицированных модулей и фрагментов.

Вызов метода hotDownloadManifest

function hotDownloadManifest() {
  return new Promise(function (resolve) {
    let request = new XMLHttpRequest()
    //hot-update.json文件里存放着从上一次编译到这一次编译 取到差异
    let requestPath = '/' + hotCurrentHash + ".hot-update.json"
    request.open('GET', requestPath, true)
    request.onreadystatechange = function () {
      if (request.readyState === 4) {
        let update = JSON.parse(request.responseText)
        resolve(update)
      }
    }
    request.send()
  })
}

Вызовите метод HotDornloadUpDateChank, чтобы получить последний код модуля через запрос JSONP

function hotDownloadUpdateChunk(chunkId) {
  let script = document.createElement('script')
  script.charset = 'utf-8'
  // /main.xxxx.hot-update.js
  script.src = '/' + chunkId + "." + hotCurrentHash + ".hot-update.js"
  document.head.appendChild(script)
}

Вот зачем использоватьJSONPполучить вместо прямого использованияsocketПолучить последний код? В основном потому, чтоJSONPПолученный код может быть непосредственно выполнен.

Вызовите метод webpackHotUpdate

Когда клиент извлекает последний код после просмотра

window.webpackHotUpdate = function (chunkId, moreModules) {
  // 循环新拉来的模块
  for (let moduleId in moreModules) {
    // 从模块缓存中取到老的模块定义
    let oldModule = __webpack_require__.c[moduleId]
    // parents哪些模块引用这个模块 children这个模块引用了哪些模块
    // parents=['./src/index.js']
    let {
      parents,
      children
    } = oldModule
    // 更新缓存为最新代码 缓存进行更新
    let module = __webpack_require__.c[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
      parents,
      children,
      hot: window.hotCreateModule(moduleId)
    }
    moreModules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
    module.l = true // 状态变为加载就是给module.exports 赋值了
    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })
    hotCurrentHash = currentHash
  }
}

Реализация hotCreateModule

Поймите, что мы можем определить модули и функции обратного вызова, которые нуждаются в горячем обновлении в бизнес-коде, и хранить их вhot._acceptedDependenciesсередина.

window.hotCreateModule = function () {
  let hot = {
    _acceptedDependencies: {},
    dispose() {
      // 销毁老的元素
    },
    accept: function (deps, callback) {
      for (let i = 0; i < deps.length; i++) {
        // hot._acceptedDependencies={'./title': render}
        hot._acceptedDependencies[deps[i]] = callback
      }
    }
  }
  return hot
}

затем вwebpackHotUpdateвызывать

    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })

Наконец, вызовите метод hotApply для горячего обновления.

Этап отладки клиента

После описанной выше реализации базовой версии HMR вы можете изменить код для сохранения и просмотра в браузере вместо обновления всего, а локально обновляя код, а затем обновляя представление. Эффективность разработки значительно повышается, когда речь идет о потребностях большого количества форм.

вопрос

  • Как реализовать спецификацию commonjs?

Заинтересованные могут перейти котладить спецификацию CommonJsУзнайте, как это работает.

  • Каков процесс реализации веб-пакета и роль каждого жизненного цикла?

webpack в основном полагается наtapableЭта библиотека предоставляет ряд функций синхронизации/асинхронизации на протяжении всего жизненного цикла.webpack生命周期Исходя из этого, я реализовал простую версиюwebpack, исходник 100+ строчек, и его легко переварить с комментариями при еде.Если интересно, можно зайти посмотреть идею.

  • Использование и реализация публикации и подписки и как реализовать механизм подписки перед публикацией?

Выше также упоминалось, что требуется модель публикации-подписки, и поддерживается только функция публикации-перед-подпиской. Для некоторых более сложных сценариев может потребоваться подписка, а затем публикация, и вы можете переместить@careteen/event-emitter. Принцип реализации также довольно прост: необходимо поддерживать офлайн-стек событий для хранения событий, которые не были опубликованы и не подписаны, а все события могут быть извлечены и выполнены, когда на них подписаны.

  • Зачем использовать JSONP вместо связи через сокеты для получения обновленного кода?

Потому что то, что получается через связь через сокет, представляет собой строку строк, которые необходимо обработать. и черезJSONPПолученный код может быть непосредственно выполнен.

Прием на работу

Ощущается острая нехватка фронтенда, и те, кто заинтересован в Sohu Focus, напрямую пишут мне по электронной почтеketingwang213821@sohu-inc.comили добавьте меня vx: Careteen

Цитировать