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

Для одной из типичных задач, хранения данных в виде «ключ — значение», почти всегда разработчики на Javascript используют объект. Просто потому что объект сам по себе именно так и устроен, представляет из себя хэш-таблицу, где имя поля это и есть ключ. Но у этого есть недостаток, о котором я узнал, обжегшись на нем. Проиллюстрирую его следующим тестом:

let a = {
  'myKey': 'myValue'
}
let key = 'constructor'; // comes from outside source
let b = a[key] || 'defaultValue';
expect(b).to.be.equal('defaultValue'); // fails

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

Рассказывать подробности того, по какой причине это сломалось, смысла не имеет, то что произошло — совершенно очевидно. Вероятность наткнуться на такое поведение невысока, но тем не менее, как выяснилось, ненулевая. Первое, что я хотел сделать, это найти библиотеку, которая это решает, предоставляет интерфейс хэш таблицы. Но, поискав, я нашел лишь библиотеки, которые полностью изолируются от использования объекта, и честно вычисляют хэш. Они, конечно, применимы и даже необходимы, если в качестве ключа нужно использовать объект, а не строку или число. Но у них есть и вытекающий недостаток — они накладывают некоторый «оверхед», на который не все могут согласиться. После недолгих мытарств я пришел к тому, что нужно таки написать еще один велосипед.

Библиотека hash-map решает эту задачу методом сокрытия всех нижележащих полей пустыми значениями:

  const result = {};
  for (var prop of Object.getOwnPropertyNames(Object.prototype)) {
    result[prop] = undefined;
  }
  return result;

Способы использования можно посмотреть в readme.

Yet another JS library


Статья получилась короткая, да и нечего особо рассказать об этой микропроблеме. Поэтому я до кучи решил упомянуть об еще одной библиотечке — typescript-reexport-generator. В процессе разработки на typescript я прибегал к разным способам экспортировать-импортировать код между файлами, пришел к тому, что наиболее удобным является следующее. Все .ts файлы в папке экспортируют код следующим образом:

// file1
export function myFunction(){}

// file2
export class myClass{}

Далее в папку кладется файл index.ts со следующим содержимым:

export * from './file1';
export * from './file2';

Теперь можно импортировать можно вот так:

//было
import { myFunction } from './folder/file1';
import { myClass } from './folder/file2';
//стало
import { myFunction, myClass } from './folder';

кроме этого, между файлами одной папки можно импортироваться вот так:

import { myClass } from '.';
export function myFunction(){ // doSmth with class }

Есть еще один мини-выигрыш: навигация в VSCode (ctrl + mouse click) наилучшим образом работает с таким экспортированием. Навигация от использования до имплементации в 1 клик. С default экспортом навигация осуществлялась в два клика, что несколько удручало, поэтому я от такого довольно быстро полностью отказался.

И для того, чтобы не писать эти реэкспорты руками, я написал простенький генератор, который создает эти файлы index.ts из таски с gulp.watch. Если вы используете такой же способ импортов-экспортов, библиотека может оказаться полезной.

Недостаток библиотеки, а куда же без них, это то, что VSCode не следит за изменениями файлов, поэтому только что созданный файл с экспортами не сразу позволяет импортироваться снаружи. Приходится руками зайти в index, чтобы студия «увидела», что там появилась новая строчка. Другой недостаток, который уже зависит от меня — gulp.watch не сообщает что именно изменилось, соответственно генератору приходится просматривать (и парсить) все файлы в проекте. В будущем возможно создам следующую версию библиотеки где это будет решено. Полным будет только первый проход, а далее будут парситься только те файлы, которые были изменены.

Комментарии (26)


  1. aamonster
    24.09.2017 16:50
    +1

    Лень проверять, но всё же: стандартный Map (ECMAScript 2016, но вроде уже давно есть во всех браузерах) проблему не решает?


    1. Aingis
      24.09.2017 17:03
      +16

      Решает. Более того, даже Object.create(null) из ES5 её решает (IE9+). Шёл 2017 год…


      1. VladVR Автор
        24.09.2017 18:02

        Действительно, отсутствие прототипа помогает. Но нельзя делать вот так, например.
        newMap = {...oldMap1, 'newKey': 'newValue' }

        Кстати в lodash метод _.groupBy тоже этому подвержен.

        Насчет Map — честно говоря, сейчас не помню почему ещё мне не захотелось вызывать методы get и set.


        1. VladVR Автор
          24.09.2017 18:24

          Вспомнил. Cейчас же неделя react vs vue vs angular. В redux есть одно неочевидное требование. Объекты, которые возвращает редюсер должны быть сериализуемы.
          Если мы захотим сохранить состояние для последующего восстановления и использования, то в случе Map, после сериализации-десериализации будем иметь не тоже самое, что было до. Это же касается и объекта без прототипа.


          1. VladVR Автор
            24.09.2017 21:39

            Немного подумал, написал вот такой тест. Оказалось, что падает

              it('should return no value for prototype property after serialize/deserialize action', async function (): Promise<void> {
                // arrange
                const map = stringMap();
            
                // act
                const deserialized = JSON.parse(JSON.stringify(map));
                const ctor = deserialized['constructor'];
            
                // assert
                expect(noValue(ctor)).to.be.equal(true);
              });
            

            починил, не смог определить, это breaking change или нет. У меня, по крайней мере ничего не сломалось


            1. foxmuldercp
              25.09.2017 11:51

              А для новичков — что за тестовый Фреймворк и где почитать*


              1. JokerNN
                25.09.2017 12:48

                1. JokerNN
                  26.09.2017 18:38

                  Был не прав!


              1. VladVR Автор
                25.09.2017 12:54

                Я использовал в качестве тест-раннера Mocha. И еще chai (assertion library)


            1. 8bitjoey
              25.09.2017 12:10

              У редакса вообще много проблем с (де)сериализацией. Даже не надо такой экзотики как Map, достаточно вспомнить Date. При сериализации он становится строкой и строкой же десериализуется. И есть вполне рабочие методы избежать этого, которые, думается, можно применить и в вашем случае.


  1. mickvav
    24.09.2017 19:07
    +2

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


    1. mnaoumov
      25.09.2017 11:30
      -1

      Да, я тоже недоумении, мне эта мысль пришла сразу же в голову...


  1. kaljan
    24.09.2017 23:53

    gulp.watch знает, какие файлы были изменены

    gulp.watch(jsFiles, ['minjs']).on("change", callEslintAfterJsChanged);
    
    var callEslintAfterJsChanged = function(file) {
                gulp.src(file.path)
                    .pipe(eslint())
                    .pipe(eslint.format());
            }
    
    


  1. inook
    24.09.2017 23:53

    Вместо

    const a = {};
    нужно использовать
    const a = Object.create(null);
    , если очень хочется реализовать структуру Map


  1. dem0n3d
    25.09.2017 07:55

    Они, конечно, применимы и даже необходимы, если в качестве ключа нужно использовать объект, а не строку или число.

    Не совсем так: ключами объекта могут быть ТОЛЬКО строки. Если использовать число в качестве ключа объекта, оно будет преобразовано в строку.


    1. gearbox
      25.09.2017 09:55
      +1

      Ключом объекта может быть Symbol. 2017-ый.


    1. Aingis
      25.09.2017 13:41
      +1

      А те же Map/Set могут в качестве ключей иметь объекты.


  1. trueshura
    25.09.2017 08:55
    +3

    ну т.е. смысл статьи в том, что если у вас в ключах могут оказаться «зарезервированные слова» (присутствующие в Object.prototype), то подумайте о том, чтобы не использовать Object?


  1. 96467840
    25.09.2017 09:42
    -1

    hasOwnProperty! не?

    let b = a.hasOwnProperty(key)? a[key] || 'defaultValue': 'defaultValue';


    1. darkslave
      25.09.2017 11:30

      Или немного покороче:

      a.hasOwnProperty(key) && a[key] || 'defaultValue';

      Правда читаемость кода…


      1. 96467840
        25.09.2017 19:42

        удалил


  1. 0shn1x
    25.09.2017 11:30

    Если вам, как вы указали выше, важно чтобы Object.Prototype объекта был не null (для redux), то почему бы не написать собственный getter, который бы выглядел как-то так:

    function get(object, key){
      return object.hasOwnProperty(key) ? object[key] : undefined
    }
    


    Таким образом вы оставите только те свойства объекта, которые принадлежат именно ему, т.е. для вашего map — только добавленные свойства


    1. VladVR Автор
      25.09.2017 12:15

      Меня не прототип null не устраивает, а то что это свойство теряется после некоторых операций. Но честно скажу не знал о возможности создавать объекты без прототипа, может бы и не стал заморачиваться с велосипедостроением.

      Геттер с этой проверкой тоже один из вариантов решения, почему нет. Одиночный оверхед во время создания против постоянного оверхеда при работе с объектом.


      1. superconductor
        25.09.2017 21:39

        Afaik, hasOwnProperty — это способ сказать движку, что не надо лезть в прототип и это прекрасно оптимизируется.


        1. VladVR Автор
          26.09.2017 14:55

          Напиши тест (с)


  1. esata
    25.09.2017 22:17
    +1

    Так есть же Map и WeakMap в ES6