Предыстория:В системе есть окно поиска для добавления брендов.При неограниченных категориях поиска весь список брендов будет иметь более 1Вт.В это время по благословению фреймворка скорость работы умиляет. допустимыйкод sandbox.io/is/pure-v ue-…Вы можете почувствовать время, когда страница перестает отвечать при переключении между загрузкой длинного списка и сбросом, даже не открывая консоль, чтобы увидеть вывод консоли.
причина проблемы
Количество узлов DOM слишком велико, а рендеринг в браузере затруднен.
(изображение взято изzhuanlan.zhihu.com/p/26022258)
На самом деле не только время начального рендеринга велико, но и если появляется большое количество узлов, то при прокрутке вы также можете отчетливо почувствовать явление неплавной прокрутки.
Альтернатива
ленивая загрузка
Через отложенную загрузку, когда появляется длинный список, все DOM-узлы не отрисовываются полностью с первого раза, и проблемы в некоторых сценариях можно решить.
Преимущество: простота реализации
недостаток:
- Очень сложно найти данные в определенном месте
- Если он загружается все время, в конечном итоге будет большое количество узлов DOM, что приведет к неплавной прокрутке.
виртуальный рендеринг
Ленивая загрузка не может соответствовать реальному отображению длинного списка, так что же нам делать, если мы действительно хотим решить такие проблемы? Другая идея: частичный рендеринг списка, также известный как виртуальный список.
Некоторые из известных на данный момент сторонних библиотек — это vue-virtual-scroller, react-tiny-virtual-list и react-virtualized. Все они могут использовать частичную загрузку, чтобы решить проблему слишком длинных списков.Решения, такие как vue-virtual-scroller и react-tiny-virtual-list, поддерживают только виртуальные списки, в то время как react-virtualized — это большая и всеобъемлющая библиотека, которая поддерживает частичную загрузку. схема для таблиц, наборов, списков и т.д.
Простой виртуальный рендеринг списка
Давайте рассмотрим решения для чисто виртуальных списков, такие как vue-virtual-scroller и react-tiny-virtual-list. Принцип их реализации заключается в использовании параллакса и иллюзии для создания «виртуального» списка, виртуальный список состоит из трех частей:
- окно
- Виртуальный список данных (отображение данных)
- Заполнитель прокрутки (нижняя область прокрутки)
Вид сбоку виртуального списка:
Передний план:
После прокрутки расстояния:
Достигаемый конечный эффект: полоса прокрутки создается заполнителем прокрутки, а виртуальный список данных отображается в визуальном окне по мере перемещения полосы прокрутки.
2D виртуальный рендеринг реактивной виртуализации
Реализация react-virtualized не такая, как мы обсуждали выше, потому что таблица двумерная, а список одномерный (его можно рассматривать как специальную таблицу), react-virtualized основан на двухмерной размерность Создан набор инструментов для рендеринга виртуальных данных.
Схематическая диаграмма выглядит следующим образом:
Синяя часть называется Cell, а блок, разделенный белой линией вверху, называется Section.
Основной принцип: Слой квадратов (Раздел) размещается вверху списка, а каждый элемент (Ячейка) ниже может приходиться на определенный квадрат (Раздел). При прокрутке, с динамическим увеличением Ячейки, Раздел будет создаваться динамически, и каждая Ячейка будет прописана под соответствующим Разделом. В соответствии с текущим прокручиваемым разделом вы можете получить ячейку, содержащуюся в текущем разделе, а затем отобразить ячейку.
/*
0 1 2 3 4 5
┏━━━┯━━━┯━━━┓
0┃0 0┊1 3┊6 6┃
1┃0 0┊2 3┊6 6┃
┠┈┈┈┼┈┈┈┼┈┈┈┨
2┃4 4┊4 3┊7 8┃
3┃4 4┊4 5┊9 9┃
┗━━━┷━━━┷━━━┛
Sections to Cells map:
0.0 [0]
1.0 [1, 2, 3]
2.0 [6]
0.1 [4]
1.1 [3, 4, 5]
2.1 [7, 8, 9]
*/План реализации
Так как нашей целью является обработка внешнего сверхдлинного списка, схема реализации react-virtualized основана на двумерных таблицах, а его компонент List также наследуется от компонента Grid.Если вы хотите сделать схему списка , необходимо сначала реализовать двумерную схему Grid. В случае работы только с длинными списками более целесообразно реализовать чисто виртуальную схему рендеринга списка, чем двумерную схему Grid.
Базовая структура
Сначала мы планируем несколько элементов в соответствии со схемой виртуального списка. .virtual-scroller — это весь компонент списка прокрутки, и его события прокрутки отслеживаются на самом внешнем уровне. Внутри нам нужно поместить фантом, чтобы открыть контейнер, чтобы появилась полоса прокрутки, а высота элемента = общие данные * высота элемента списка. Затем мы рисуем список ul на верхнем уровне .phantom, который используется для динамической загрузки данных, и будет рассчитана его позиция и данные.
код sandbox.IO/is/list — жители провинции Сычуань…
<template>
<div id="app">
<input type="text" v-model.number="dataLength">条
<div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
<div class="phantom" :style="{height: this.dataLength * itemHeight + 'px'}">
<ul :style="{'margin-top': `${scrollTop}px`}">
<li v-for="item in visibleList" :key="item.brandId" :style="{height: `${itemHeight}px`, 'line-height': `${itemHeight}px`}">
<div>
<div>{{item.name}}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
itemHeight: 60,
visibleCount: 10,
dataLength: 100,
startIndex: 0,
endIndex: 10,
scrollTop: 0
};
},
computed: {
dataList() {
const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
brandId: i + 1,
name: `第${i + 1}项`,
height: this.itemHeight
}));
return newDataList;
},
visibleList() {
return this.dataList.slice(this.startIndex, this.endIndex);
}
},
watch: {
dataList() {
console.time('rerender');
setTimeout(() => {
console.timeEnd('rerender');
}, 0)
}
},
methods: {
onScroll(e) {
}
}
};
</script>
<style lang="stylus" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
.virtual-scroller {
border: solid 1px #eee;
margin-top: 10px;
height 600px
overflow auto
}
.phantom {
overflow hidden
}
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
}
}
</style>
Make it scroll
В предыдущем примере функция onScroll не заполнена, а это значит, что данные и позиция в виртуальном списке не будут обновляться при прокрутке. На этом шаге завершите функцию onScroll.
код sandbox.IO/is/list — жители провинции Сычуань…
<template>
<div id="app">
<input type="text" v-model.number="dataLength">条
<div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
<div class="phantom" :style="{height: this.dataLength * itemHeight + 'px'}">
<ul :style="{'margin-top': `${scrollTop}px`}">
<li v-for="item in visibleList" :key="item.brandId" :style="{height: `${itemHeight}px`, 'line-height': `${itemHeight}px`}">
<div>
<div>{{item.name}}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
itemHeight: 60,
visibleCount: 10,
dataLength: 100,
startIndex: 0,
endIndex: 10,
scrollTop: 0
};
},
computed: {
dataList() {
const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
brandId: i + 1,
name: `第${i + 1}项`,
height: this.itemHeight
}));
return newDataList;
},
visibleList() {
return this.dataList.slice(this.startIndex, this.endIndex);
}
},
watch: {
dataList() {
console.time('rerender');
setTimeout(() => {
console.timeEnd('rerender');
}, 0)
}
},
methods: {
onScroll(e) {
const scrollTop = e.target.scrollTop;
this.scrollTop = scrollTop;
console.log('scrollTop', scrollTop);
this.startIndex = Math.floor(scrollTop / this.itemHeight);
this.endIndex = this.startIndex + 10;
}
}
};
</script>
<style lang="stylus" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
.virtual-scroller {
border: solid 1px #eee;
margin-top: 10px;
height 600px
overflow auto
}
.phantom {
overflow hidden
}
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
}
}
</style>
Решить проблему бессвязной прокрутки
В предыдущем примере при прокрутке мы обнаружим, что содержимое виртуального списка будет обновляться внезапно после прокрутки на определенное расстояние, а не постепенно.
Это связано с тем, что startIndex рассчитывается как scrollTop/itemHeight, который может быть кратен только высоте элемента.Предполагая, что значение scrollTop находится между 1 и 2 раза, startIndex в виртуальном списке не будет обновляться, и прокрутка не произойдет.
Итак, как решить эту проблему? По сути, мы используем прокрутку самого элемента ul для «обмана глаз», принцип показан на следующем рисунке:
Просто немного подправьте нашу функцию onScroll. Верхнее поле ul вычисляется вместо непосредственного использования e.target.scrollTop.
onScroll(e) {
const scrollTop = e.target.scrollTop;
this.startIndex = Math.floor(scrollTop / this.itemHeight);
this.endIndex = this.startIndex + 10;
this.scrollTop = this.startIndex * this.itemHeight;
}уменьшить оплавление
Поскольку нам нужно менять margin-top каждый раз, когда мы прокручиваем страницу, это может привести к частым перекомпоновкам, поэтому мы можем рассмотреть возможность уменьшения частоты изменений margin-top.
onScroll(e) {
const scrollTop = e.target.scrollTop;
const startIndex = Math.floor(scrollTop / this.itemHeight);
let endIndex = startIndex + 10;
if (endIndex > this.dataList.length) {
endIndex = this.dataList.length;
}
// 当前滚动高度
const currentScrollTop = startIndex * this.itemHeight;
// 如果往下滚了可视区域的一部分,或者往上滚任意距离
if (currentScrollTop - this.scrollTop > this.itemHeight * (this.visibleCount - 1) || currentScrollTop - this.scrollTop < 0) {
this.scrollTop = currentScrollTop;
this.startIndex = startIndex;
this.endIndex = endIndex;
}
}Высота элемента списка не фиксирована, но может быть получена перед рендерингом
Вышеупомянутая обработка в основном относится к записи мертвой высоты.Если высота получена из данных, ее необходимо переписать следующим образом.
код sandbox.IO/is/list — жители провинции Сычуань…
<template>
<div id="app">
<input type="text" v-model.number="dataLength">条{{this.scrollBarHeight}}
<div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
<div class="phantom" :style="{height: this.scrollBarHeight + 'px'}">
<ul :style="{'margin-top': `${scrollTop}px`}">
<li v-for="item in visibleList" :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}">
<div>
<div>{{item.name}}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
visibleCount: 10,
dataLength: 2000,
startIndex: 0,
endIndex: 10,
scrollTop: 0,
bufferItemCount: 4,
dataList: []
};
},
computed: {
visibleList() {
return this.dataList.slice(this.startIndex, this.endIndex + this.bufferItemCount);
},
scrollBarHeight() {
return this.dataList.reduce((pre, current)=> {
console.log(pre, current)
return pre + current.height;
}, 0);
}
},
watch: {
dataList() {
console.time('rerender');
setTimeout(() => {
console.timeEnd('rerender');
}, 0)
}
},
mounted() {
this.dataList = this.getDataList();
},
methods: {
getDataList() {
const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
brandId: i + 1,
name: `第${i + 1}项`,
height: Math.floor(Math.max(Math.random() * 10, 5)) * 10
}));
return newDataList;
},
getScrollTop(startIndex) {
return this.dataList.slice(0, startIndex).reduce((pre, current) => {
return pre + current.height;
}, 0)
},
getStartIndex(scrollTop) {
let index = 0;
let heightAccumulate = 0;
for (let i = 0; i < this.dataList.length; i++) {
if (heightAccumulate > scrollTop) {
index = i - 1;
return index;
}
if (heightAccumulate === scrollTop) {
index = i;
return i
}
heightAccumulate += this.dataList[i].height;
}
return index;
},
onScroll(e) {
const scrollTop = e.target.scrollTop;
this.startIndex = this.getStartIndex(scrollTop);
this.endIndex = this.startIndex + 10;
this.scrollTop = this.getScrollTop(this.startIndex);
}
}
};
</script>
<style lang="stylus" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
.virtual-scroller {
border: solid 1px #eee;
margin-top: 10px;
height 600px
overflow auto
}
.phantom {
overflow hidden
}
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
}
}
</style>Кэш прокруткиВверху каждого элемента
В предыдущем примере нам нужно каждый раз пересчитывать getScrollTop, что является пустой тратой производительности. Вы можете добавить кеш в начале, чтобы каждый звонок брался напрямую с карты, что занимает меньше времени.
код sandbox.IO/is/list — жители провинции Сычуань…
generatePositionCache() {
const allHeight = this.dataList.reduce((pre, current, i) => {
const heightSum = pre + current.height;
this.positionCache[i] = pre;
return heightSum;
}, 0)
this.scrollBarHeight = allHeight
}Бинарный поиск сокращает время поиска startIndex
Кроме того, бинарный поиск также можно использовать для сокращения времени getStartIndex.
getStartIndex(scrollTop) {
// 在itemTopCache中找到一个左侧小于scrollTop但右侧大于scrollTop的位置
// 复杂度O(n)
// for (let i = 0; i < this.itemTopCache.length; i++) {
// if (this.itemTopCache[i] > scrollTop) {
// return i - 1;
// }
// }
// 复杂度O(logn)
let arr = this.itemTopCache;
let index = -1;
let left = 0,
right = arr.length - 1,
mid = Math.floor((left + right) / 2);
let circleTimes = 0;
while (right - left > 1) {
// console.log('index: ', left, right);
// console.log('height: ', arr[left], arr[right]);
circleTimes++;
// console.log('circleTimes:', circleTimes)
// 目标数在左侧
if (scrollTop < arr[mid]) {
right = mid;
mid = Math.floor((left + right) / 2);
} else if (scrollTop > arr[mid]) {
// 目标数在右侧
left = mid;
mid = Math.floor((left + right) / 2);
} else {
index = mid;
return index;
}
}
index = left;
return index;
}Исправление проблем с индексацией CSS
Обычная структура списка начинается с 0-го элемента, и мы можем выбрать список четных строк в CSS с помощью селектора 2n. Но виртуальный список другой.Начальный индекс, который мы вычисляем, каждый раз разный.Когда начальный индекс является нечетным числом, 2n ведет себя ненормально, поэтому нам нужно убедиться, что начальный индекс является четным числом. Решение тоже очень простое: если окажется, что это нечетное число, взять предыдущую цифру, чтобы гарантировать, что startIndex должен быть четным числом.
код sandbox.IO/is/list — жители провинции Сычуань…
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
&:nth-child(2n) {
background: #fff;
}
}
}
...
// onScroll中加入
// 如果是奇数开始,就取其前一位偶数
if (startIndex % 2 !== 0) {
this.startIndex = startIndex - 1;
} else {
this.startIndex = startIndex;
}Высота может быть определена только после рендеринга
В одном случае количество текста, содержащегося в каждом элементе списка, разное, что приводит к разной высоте после рендеринга. Затем мы можем обновить высоту элемента списка после монтирования компонента.
код sandbox.IO/is/list — жители провинции Сычуань…
Item.vue
<template>
<li :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}" ref="node">
<div>
<div>{{item.name}}</div>
</div>
</li>
</template>
<script>
export default {
props: {
item: {
default() {
return {}
},
type: Object
},
index: Number
},
data() {
return {
}
},
mounted() {
this.$emit('update-height', {height: this.$refs.node.getBoundingClientRect().height, index: this.index})
}
}
</script>
Компонент Item будет обновлять высоту при загрузке, но что, если весь список инициализируется без высоты? Нам нужно ввести оценочное значение: предполагаемая высота элемента, которое представляет предполагаемую высоту каждого элемента.Каждый раз, когда элемент обновляется, оценочное значение заменяется, а общая высота списка обновляется.
App.vue
<template>
<div id="app">
<input type="text" v-model.number="dataLength">条 Height:{{scrollBarHeight}}
<div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
<div class="phantom" :style="{height: scrollBarHeight + 'px'}">
<ul :style="{'transform': `translate3d(0,${scrollTop}px,0)`}">
<Item v-for="item in visibleList" :item="item" :index="item.index" :key="item.brandId" @update-height="updateItemHeight"/>
</ul>
</div>
</div>
</div>
</template>
<script>
import Item from './components/Item.vue';
export default {
name: "App",
components: {
Item
},
data() {
return {
estimatedItemHeight: 30,
visibleCount: 10,
dataLength: 200,
startIndex: 0,
endIndex: 10,
scrollTop: 0,
scrollBarHeight: 0,
bufferItemCount: 4,
dataList: [],
itemHeightCache: [],
itemTopCache: []
};
},
computed: {
visibleList() {
return this.dataList.slice(this.startIndex, this.endIndex + this.bufferItemCount);
}
},
watch: {
dataList() {
console.time('rerender');
setTimeout(() => {
console.timeEnd('rerender');
}, 0)
}
},
created() {
this.dataList = this.getDataList();
this.generateEstimatedItemData();
},
mounted() {
},
methods: {
generateEstimatedItemData() {
const estimatedTotalHeight = this.dataList.reduce((pre, current, index)=> {
this.itemHeightCache[index] = this.estimatedItemHeight;
const currentHeight = this.estimatedItemHeight;
this.itemTopCache[index] = index === 0 ? 0 : this.itemTopCache[index - 1] + this.estimatedItemHeight;
return pre + currentHeight
}, 0);
this.scrollBarHeight = estimatedTotalHeight;
},
updateItemHeight({index, height}) {
this.itemHeightCache[index] = height;
this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => {
return pre + current;
}, 0)
let newItemTopCache = [0];
for (let i = 1, l = this.itemHeightCache.length; i < l; i++) {
newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1]
};
this.itemTopCache = newItemTopCache;
},
getDataList() {
const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
index: i,
brandId: i + 1,
name: `第${i + 1}项`,
height: Math.floor(Math.max(Math.random() * 10, 5)) * 10
// height: 50
}));
return newDataList;
},
getStartIndex(scrollTop) {
// 在heightAccumulateCache中找到一个左侧小于scrollTop但右侧大于scrollTop的位置
// 复杂度O(n)
// for (let i = 0; i < this.itemTopCache.length; i++) {
// if (this.itemTopCache[i] > scrollTop) {
// return i - 1;
// }
// }
// 复杂度O(logn)
let arr = this.itemTopCache;
let index = -1;
let left = 0,
right = arr.length - 1,
mid = Math.floor((left + right) / 2);
let circleTimes = 0;
while (right - left > 1) {
circleTimes++;
// 目标数在左侧
if (scrollTop < arr[mid]) {
right = mid;
mid = Math.floor((left + right) / 2);
} else if (scrollTop > arr[mid]) {
// 目标数在右侧
left = mid;
mid = Math.floor((left + right) / 2);
} else {
index = mid;
return index;
}
}
index = left;
return index;
},
onScroll(e) {
const scrollTop = e.target.scrollTop;
console.log('scrollTop', scrollTop);
let startIndex = this.getStartIndex(scrollTop);
// 如果是奇数开始,就取其前一位偶数
if (startIndex % 2 !== 0) {
this.startIndex = startIndex - 1;
} else {
this.startIndex = startIndex;
}
this.endIndex = this.startIndex + this.visibleCount;
this.scrollTop = this.itemTopCache[this.startIndex] || 0;
}
}
};
</script>
<style lang="stylus" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
.virtual-scroller {
border: solid 1px #eee;
margin-top: 10px;
height 600px
overflow auto
}
.phantom {
overflow hidden
}
ul {
background: #ccc;
list-style: none;
padding: 0;
margin: 0;
li {
outline: solid 1px #fff;
&:nth-child(2n) {
background: #fff;
}
}
}
</style>Если элемент списка содержит тег img и автоматически открывается img, то мы можем использовать событие onload img, чтобы уведомить список об обновлении высоты. Также есть пример использования img с компонентом CellMeasure в react-virtualized. Так что, если вы столкнетесь с более сложной сценой изменения высоты?
ResizeObserver
Интерфейс ResizeObserver может отслеживать изменения в области содержимого Element или ограничивающей рамки SVGElement, а также обрабатывать сложные сценарии изменения высоты.
Но совместимость ResizeObserver более общая:rub news.com/#feat=Тепло умирает...
Хотя совместимость не очень хорошая, его все же можно использовать в некоторых фоновых системах.
Пример использования ResizeObserve
код sandbox.IO/is/list — жители провинции Сычуань…
код sandbox.IO/is/list — жители провинции Сычуань…
Основной пункт корректировки — добавить в элемент списка методы наблюдения и отсутствия наблюдения.
Item.vue
<template>
<li :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}" ref="node">
<div>
<div>{{item.name}}</div>
</div>
</li>
</template>
<script>
export default {
props: {
item: {
default() {
return {}
},
type: Object
},
index: Number
},
data() {
return {}
},
mounted() {
this.observe();
},
methods: {
observe() {
this.resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
console.log(this.index, entry.contentRect.height)
this.$emit('update-height', {height: entry.contentRect.height, index: this.index})
});
this.resizeObserver.observe(this.$refs.node);
},
unobserve() {
this.resizeObserver.unobserve(this.$refs.node);
}
},
beforeDestroy() {
this.unobserve();
}
}
</script>Используйте библиотеку изменения размера для отслеживания высоты
Для очень изменчивых сцен и высоких требований к совместимости мы можем использовать его полифилл:ResizeObserver Polyfill, поддержка IE8 и выше. Также обратите внимание на некоторые из его ограничений:
- Notifications are delivered ~20ms after actual changes happen.
- Changes caused by dynamic pseudo-classes, e.g.
:hoverand:focus, are not tracked. As a workaround you could add a short transition which would trigger thetransitionendevent when an element receives one of the former classes (example). - Delayed transitions will receive only one notification with the latest dimensions of an element.
Если вы хотите реализовать наблюдение за обновлением размера после :hover и :focus без собственного ResizeObserver, используйтеelement-resize-detector,javascript-detect-element-resize(react-virtualized uses) такие сторонние библиотеки, конечно, у них тоже есть некоторые ограничения, с которыми можно ознакомиться вobservation-strategyСмотрите подробно.
Суммировать
После решения вышеупомянутого ряда проблем мы реализовали относительно простой виртуальный список. Исправление некоторых проблем совместимости и оптимизация производительности должны основываться на реальной ситуации. В производственной среде рекомендуется напрямую использовать зрелые сторонние библиотеки, которые гарантируют совместимость и производительность. Если у вас есть достаточно времени, вы можете построить колесо, чтобы понять идею, которая будет более удобной при использовании сторонних компонентов.
Справочная статья:
Surplus.ant fin-Inc.com/Abraham.Spring Festival/…
developer.Mozilla.org/this-cn/docs/…
GitHub.com/but-etc/горячая вода…
