SpreadsheetApp: читаем и пишем
Три объекта — книга, лист, диапазон — и одно правило, которое отличает скрипт на две секунды от скрипта на четыре минуты.
Range (диапазон) — прямоугольный кусок листа: одна ячейка, строка, столбец или блок. Всё чтение и вся запись в Apps Script идут через диапазоны, и именно от их числа зависит скорость скрипта.
Иерархия: книга → лист → диапазон
SpreadsheetApp — не сама таблица, а служба таблиц, точка входа. Дальше идут три уровня, и путать их нельзя.
// 1. Книга (Spreadsheet) — весь файл целиком
const kniga = SpreadsheetApp.getActiveSpreadsheet();
// 2. Лист (Sheet) — одна вкладка внизу
const list = kniga.getSheetByName('Заявки');
// 3. Диапазон (Range) — прямоугольник ячеек
const diapazon = list.getRange('B2:D10');Что здесь важно:
getActiveSpreadsheet()— книга, к которой привязан скрипт. Для отдельного проекта или чужого файла берутSpreadsheetApp.openById('1AbC...'), где идентификатор — длинный кусок из адресной строки таблицы.getSheetByName('Заявки')— лист по имени на ярлычке. Если такого листа нет, метод вернётnull, а не бросит ошибку. Упадёт уже следующая строка — с загадочнымTypeError: Cannot read properties of null. Имя чувствительно к регистру и к лишним пробелам.getRange(...)— прямоугольник ячеек. Задать его можно двумя способами.
Два способа адресовать диапазон
list.getRange('C5'); // одна ячейка, привычная A1-нотация
list.getRange('A1:D100'); // блок
list.getRange('B:B'); // весь столбец B
list.getRange(5, 3); // строка 5, столбец 3 — та же самая C5
list.getRange(2, 1, 99, 4); // от A2: 99 строк, 4 столбца — тот же A2:D100Форма getRange(строка, столбец, сколькоСтрок, сколькоСтолбцов) незаменима, когда границы вычисляются в коде. Нумерация здесь начинается с единицы: getRange(1, 1) — это A1. Запомните контраст: сам лист считается с 1, а массивы значений, которые вы вот-вот получите, — с нуля.
Чтение: getValue и getValues
const odno = list.getRange('B2').getValue(); // одно значение: 'Иванов'
const mnogo = list.getRange('A2:C4').getValues(); // двумерный массивgetValues() всегда возвращает массив массивов — «строки, а в каждой строке ячейки», даже если диапазон состоит из одной колонки или одной ячейки.
// mnogo === [
// ['2026-07-01', 'Иванов', 12000],
// ['2026-07-01', 'Петрова', 8500],
// ['2026-07-02', 'Иванов', 15000]
// ]
const summaVtoroy = mnogo[1][2]; // 8500 — нумерация с НУЛЯОтсюда типовой шаблон обхода таблицы с шапкой.
function itogiPoMenedzheram() {
const list = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Заявки');
const stroki = list.getDataRange().getValues(); // всё заполненное — ОДНИМ вызовом
const itogi = {};
for (let i = 1; i < stroki.length; i++) { // i = 1 — пропускаем шапку
const menedzher = stroki[i][1];
const summa = Number(stroki[i][2]) || 0;
itogi[menedzher] = (itogi[menedzher] || 0) + summa;
}
console.log(itogi); // { Иванов: 27000, Петрова: 8500 }
}getDataRange() — удобное сокращение: «диапазон от A1 до последней заполненной строки и столбца». Не нужно ни getLastRow(), ни getLastColumn(), ни риска промахнуться на строку.
Запись: setValue и setValues
list.getRange('E1').setValue('Итого');
list.getRange('A10:C11').setValues([
['2026-07-03', 'Сидоров', 9000],
['2026-07-03', 'Иванов', 4000]
]);У setValues() железное требование: размер массива обязан в точности совпадать с размером диапазона. A10:C11 — это две строки по три столбца, значит нужен массив из двух подмассивов по три элемента. Не совпало — получите The number of rows in the data does not match the number of rows in the range. Это ошибка номер один при первой записи.
Надёжнее не хардкодить границы, а вычислять их от самих данных:
const dannye = [
['2026-07-03', 'Сидоров', 9000],
['2026-07-03', 'Иванов', 4000]
];
list
.getRange(list.getLastRow() + 1, 1, dannye.length, dannye[0].length)
.setValues(dannye);Такой код дописывает блок под существующие данные и никогда не разъезжается по размерности. Для одной-единственной строки есть ещё короче: list.appendRow(['2026-07-03', 'Сидоров', 9000]).
Главное правило скорости: диапазоном, а не по ячейке
Вот два скрипта, которые делают ровно одно и то же — записывают 1000 чисел в столбец A.
Плохо — тысяча отдельных обращений к сервису:
function medlenno() {
const list = SpreadsheetApp.getActiveSheet();
for (let i = 1; i <= 1000; i++) {
list.getRange(i, 1).setValue(i * 2); // 1000 сетевых вызовов
}
}Хорошо — один вызов:
function bystro() {
const list = SpreadsheetApp.getActiveSheet();
const dannye = [];
for (let i = 1; i <= 1000; i++) {
dannye.push([i * 2]); // готовим массив в памяти — это практически бесплатно
}
list.getRange(1, 1, dannye.length, 1).setValues(dannye); // 1 сетевой вызов
}Разница не в процентах, а в разы: первый вариант тянется десятки секунд, а на пяти-десяти тысячах строк упирается в шестиминутный лимит и падает. Второй отрабатывает меньше чем за секунду. Причина ровно та, о которой шла речь в первом уроке: каждый getValue и setValue — это сетевой вызов от сервера Apps Script к серверу Таблиц. Сам цикл в JavaScript почти ничего не стоит; стоит поход по сети.
Отсюда универсальный рецепт любого скрипта для таблиц:
- Прочитать всё нужное одним
getValues(). - Посчитать в обычном JavaScript, на массивах в памяти — здесь можно всё и это быстро.
- Записать результат одним
setValues().
«Прочитал — посчитал — записал». Почти любой медленный скрипт лечится приведением к этой схеме.
А если ячейки нужно красить по одной?
Форматирование подчиняется тому же закону. У диапазона есть множественные версии почти всех методов: setBackgrounds(), setFontColors(), setNumberFormats() — они принимают такой же двумерный массив.
const stroki = list.getDataRange().getValues();
const cveta = stroki.map(stroka => {
const prosrocheno = stroka[3] === 'просрочено';
return [prosrocheno ? '#ffd6d6' : '#ffffff'];
});
list.getRange(1, 5, cveta.length, 1).setBackgrounds(cveta);Одно чтение, один map в памяти, одна запись — вместо тысячи setBackground() в цикле.
Как это работает
- Скрипт и таблица — разные сервисы Google, между ними сеть. Объект
Range— это не данные, а адрес: пока вы не вызвалиgetValues(), ничего ещё не прочитано. - Apps Script копит операции записи в буфер и отправляет их пачкой. Поэтому лист иногда «обновляется не сразу». Сбросить буфер немедленно можно вызовом
SpreadsheetApp.flush()— он нужен редко: например, когда вы записали значение и следующей же строкой хотите прочитать пересчитанную формулу. getValues()возвращает типизированные значения, а не строки: даты приходят объектамиDate, числа —number, пустая ячейка — пустой строкой. Если нужна ровно та строка, которую видит пользователь, со всем форматированием, естьgetDisplayValues().getLastRow()— последняя строка с данными на всём листе, а не в вашем столбце. Если справа висит подпись в 500-й строке, метод вернёт 500, даже когда полезных данных всего двадцать строк.
Частые ошибки
- Цикл с
getValue()илиsetValue()внутри. Самая дорогая ошибка платформы. Выносите чтение и запись за пределы цикла — всегда. Cannot read properties of nullсразу послеgetSheetByName. Листа с таким именем нет: опечатка, другой регистр, лишний пробел на ярлычке. Страхуйтесь:if (!list) throw new Error('Нет листа Заявки');- Путаница в нумерации.
getRangeсчитает с единицы, массив отgetValues— с нуля. Пятая строка листа — этоstroki[4]. setValuesс одномерным массивом.setValues([1, 2, 3])упадёт. Нужен двумерный:[[1, 2, 3]]для строки или[[1], [2], [3]]для столбца.getRange('A2:A' + list.getLastRow())на пустом листе.getLastRow()вернёт 0, получится строка'A2:A0'— невалидный диапазон и ошибка. Проверяйте, что данные вообще есть.- Сравнение дат как строк. В ячейке лежит
Date, поэтомуstroki[i][0] === '2026-07-01'никогда не окажется истиной. Приводите явно:Utilities.formatDate(stroki[i][0], 'Europe/Moscow', 'yyyy-MM-dd').
Итоги
- Иерархия:
SpreadsheetApp→ книга (getActiveSpreadsheetилиopenById) → лист (getSheetByName) → диапазон (getRange). getValues()иsetValues()работают с двумерными массивами; размер массива обязан совпадать с размером диапазона.getDataRange()— самый быстрый способ забрать все данные листа одним вызовом.getRangeнумерует с единицы, массивы — с нуля. Половина ошибок новичка растёт отсюда.- Главное правило: прочитал одним вызовом — посчитал в памяти — записал одним вызовом. Цикл с
setValueвнутри — прямая дорога к шестиминутному таймауту.