Чем живет мобильный Островок

в 8:49, , рубрики: coffeescript, javascript, node.js, Ostrovok.ru, Мобильный веб, метки: , , , ,

Всем привет. Меня зовут Макс Дегтерев (у меня тут нет аккаунтра, так что вот мой twitter: @suprMax и сайт maxdegterev.name ). Мы недавно запустили новую классную версию мобильного сайта. Про него-то я вам сейчас и расскажу.

Чем живет мобильный Островок

Чем живет мобильный Островок

Чем живет мобильный Островок

Основная основа

Работая над новым мобильным сайтом, я уже имел за плечами опыт разработки старого. На момент написания этих букв он еще должен быть доступен по адресу http://ostrovok.ru/m, но сколько он проработает, я не знаю. Старый сайт работал очень просто. Он живет в общем проекте OTA и, соответственно, использует тот же Back-end (Django), стандартный медиагенератор, простые HTML шаблоны (server-side), немного JavaScipt шаблонов на underscore, SCSS и обычный JavaScipt. Это все мы уже проходили сотню раз, это все очень скучно.

Перед разработкой нового сайта у меня стояли следующие задачи:

  • Сделать сайт в новом дизайне, заодно посмотреть, как этот новый дизайн будет вообще работать, и стоит ли его использовать для мобильного приложения в будущем.
  • Сайт должен хорошо работать на iPhone и Android 2.x, 4.x, default браузеры.
  • Добавить некоторые новые функции, которых нет на старом. Например, фильтр по названию отеля и добавление отелей в favorites. Конечно, полностью копировать большой сайт не следует.
  • Сделать чтобы было прикольно и интересно.

Так уж исторически сложилось, что сайт всегда разрабатывается с дизайном мобильного приложения, этот дизайн на нем обкатывается. Вроде такого эксперимента. Вот и тут дизайн был абсолютно новый, flat (iOS 7 style, модно!). Поэтому не удивляйтесь, что цвета, да и вообще внешний вид, немного отличается от всего остального Островка.

Мне не хотелось зависеть от OTA, как в прошлый раз. Плюс я работал над сайтом один, и учить Django совсем не интересно, а постоянно отвлекать кого-то из разработчиков OTA – не комильфо. Поэтому я решил мыслить как пират. В Островке уже есть мобильное API, которое покрывает бóльшую часть того, что требовалось от мобильного сайта. Сдобрив это частями API от десктопного сайта, можно получить вполне себе неплохое решение. Именно такой подход я и выбрал.

За основу я взял NodeJS. Всегда хотелось попробовать. В качестве фреймворка выступил ExpressJS, который очень похож на Sinatra (привет Ruby!). Мне нужно было вести пользовательские сессии, так что без Redis не обойтись. Чтобы меньше печатать вместо обычного JavaScript использовал CoffeeScript. Для шаблонов взял Jade, а для CSS — Stylus. Нам нужен модный сайт, да и на мобильных телефонах можно получить большой выигрыш в скорости, так что все решил делать singlepage. Сначала думал взять SpineJS, но у него комьюнити не особо большое, поэтому классическая связка: Backbone, Lo-Dash, Zepto.

Чем живет мобильный Островок

Пришлось немного повозиться со сборкой ассетов. Хочется, чтобы все было прикольно, автоматически, как у рельсовиков. Вполне неплохим решением оказался asset-rack, который полностью берет на себя задачи сборки JS, CSS и даже Jade шаблонов. Конечно же он умеет прекомпилировать шаблоны, кладет их в указанный неймспейс (я выбрал app.templates). Так же он берет на себя головную боль по прекомпиляции CoffeeScript и системе requirements, предоставляя 2 варианта: Snockets (Sprockets) и классический для NodeJS вариант с RequireJS (Browserify). Тем не менее NodeJS штука новая, так что были проблемы:

  • Статика компилируется на момент запуска сервера. Что-то поменялось в файле — изволь перезапустить сервер. На production проблем нет, а вот во время разработки не особо удобно. Решается установкой nodemon. Далее достаточно написать простую инструкцию в Cakefile и стартовать сервер, например, так: `cake dev`. И нет проблем.
  • Статика компилируется, но не кладется на диск. Asset-rack умеет загружать файлы на хостинг (Amazon S3 etc.), но вот если хочется самому отдавать их, например, через nginx, то приходится проявить изобретательность:
            compressAsset = (filename, contents)->
              console.log("[#{(new Date()).toUTCString()}] #{logPrefix} Compressing asset #{filename}.gz")
              zlib.gzip(contents, (e, buffer) -> fs.writeFile(filename + '.gz', buffer))
    
            generateAssets = ->
              for asset in assets.assets
                filename = __dirname + '/public' + asset.url.replace('.', "-#{asset.md5}.")
                if not fs.existsSync(filename)
                  console.log("[#{(new Date()).toUTCString()}] #{logPrefix} Saving asset #{filename}")
                  fs.writeFileSync(filename, asset.contents, encoding: 'utf-8')
                  compressAsset(filename, asset.contents)
    
            assets.on('complete', ->
              generateAssets() unless config.is_dev
          

Подитог:

  • Back-end и Front-end написаны на одном языке. Один codestyle, а так же можно подключать одни и те же файлы. Например, валидация данных и helpers шаблонов. Ну и как дополнительный бонус — фронтендеры могут читать бекенд код и понимать, что вообще происходит.
  • Кстати о шаблонах. Jade можно использовать как на клиенте, так и на сервере. В моем случае я подключаю один layout.jade на все страницы. Но если я захочу, я могу отдать абсолютно любую страницу, сгенерировав ее на back-end. Прокинув правильную структуру данных конечно, но удобство очевидно.
  • Stylus — это просто божественно. Можно полностью копировать Jade шаблон в Stylus файл, удалять оттуда все лишнее и писать стили. Это то, как должен разрабатываться веб.
  • Простые понятные JSON конфиги на Back-end, Front-end, куда угодно.

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

Как все это заработало

Немного о том, как организован сам проект. A picture is worth a thousand words:
Чем живет мобильный Островок
Чем живет мобильный Островок
Я не буду вдаваться в детали разработки на Backbone и CoffeeScript. Об этом написаны тысячи статей. Я расскажу очень кратко о структуре модулей, и вообще, как я делал, чтобы не сойти с ума.

Очевидно, что Backbone имеет collections, models и views. Я положил их в разные папки. Еще мне понадобились модули. Это такие штуки, которые нужны постоянно, вне зависимости от того, на какой странице я нахожусь. Они загружаются на старте приложения и уже не выгружаются, когда view, к примеру, уничтожаются сразу при переходе на другую страницу. Как и некоторые модели/коллекции (например, список бронирований при нажатии кнопки «Выйти»). У меня модули — это модальные окна, сайдбар, информация о физическом местоположении телефона и т.п.

В итоге вот так выглядит мой главый файл app.coffee:

    #= require ../../data/app.config.js
    #= require ../helpers.js
    #= require app.utils.js

    #= require_tree modules

    #= require router.coffee
    #= require_tree models
    #= require_tree collections
    #= require_tree views

    app = _.extend(app, Backbone.Events)

    # ...

    # Layout modules
    app.size = new app.modules.Size()

    # Data modules
    app.geo = new app.modules.Geo()
    app.user = new app.modules.User()
    app.analytics = new app.modules.Analytics()

    # Modals and extra views
    app.overlay = new app.modules.Overlay()
    app.modal = new app.modules.Modal()

    # Router relies heavily on stuff above
    app.router = new app.Router()

    # ...

    Backbone.history.start(pushState: true, hashChange: false) 

Вот и helpers.js, который используется и на Back-end. Подключение папок через require_tree дает возможность не задумываться о подключении индивидуальных файлов, но вот порядок загрузки не гарантирует, так что понадобится делать дополнительные require, если требуется наследовать от какого-то другого класса (например view). У меня такой задачи не возникало, а к моменту вызова Backbone.history.start все модули и компоненты системы уже в памяти, так что роутер может делать свою работу, вызывать view и т.д.

Теперь немножко про Stylus. Он очень похож на SASS, но имеет ряд дополнительных возможностей. Очевидно, что он identation based. Это дает возможности не заморачиваться о пересечении названий классов. Но это все мы уже давно видели, это скучно. Как насчет возможности использовать значение любого CSS правила в качестве переменной?

    .my-awesome-block
      width: 100px
      height: 100px

      margin: (@width / 2) auto

      line-height: height
  

Неплохо. А как насчет поддержки vendor prefixes? Мы живем в ужасном мире конкурентной борьбы. К счастью, выглядит это вот так:

    .my-awesome-block
      box-sizing: border-box
      transition: all .2s ease
  

Stylus сам смотрит на то, какие mixin у него есть, и если находит properties с таким же названием, то осуществляет замену. Соответственно, если больше не требуется заполнять vendor prefixes, например, для border-radius (что, кстати, правда), то можно просто удалить один mixin и даже не открывать остальные файлы. Я не уверен, но мне кажется, это может сэкономить примерно сотни времени в будущем. Ну и небольшой пример того, как выглядит мой app.styl:

    import 'config'

    import 'includes/reset.css'
    import 'includes/fonts.css'
    import 'includes/mixins'

    import 'plugins/iswipe'
    import 'plugins/zepto.sidebarify'
    import 'plugins/zepto.calendar.css'
    import 'plugins/zepto.input.numselect.css'
    import 'plugins/zepto.listselect.css'
    import 'plugins/zepto.textarea_autogrow'

    import 'partials/partial_date'
    import 'partials/partial_spinner'

    // ...  

Выходит, смешанные импорты тоже не проблема. Единственный недостаток на данный момент – для того чтобы вызвать mixin нужны аргументы. Без этого у меня не работал вызов, хотя это уже могли исправить. У меня написано вот так:

    html,
    body 
      // lol!
      noselect plz

    body, select, input, button, textarea 
      color: #4b5c66
      font: normal 14px Helvetica, Arial, sans-serif
      line-height: 1.4em

    //... 

Для стилизации я всегда использую только классы, чтобы победить злых specificity ситхов Чтобы дать 146% гарантию того, что классы ну никогда не пересекутся, я использовал подход SMACSS. Ну, или не совсем, но очень похоже. Все, что отностится к структуре страницы, и не меняется при переходах, я делал с префиксом l (layout). Так же у меня есть p (page), блоки внутри страниц b (block) и т.д. В случае вложенности элемента в элемент его класс будет наследовать в себе часть названия родителя. А может и не наследовать, но корневой класс наследуется всегда. Вот пример стилей:

    .p-awesomepage
      .p-a-header
        // styles
        .p-a-h-soopermenulink
          color: hotpink

      .p-a-content
        // styles

      .p-a-loading
        // styles

      .p-a-title
        // styles 

Для вот такой разметки:

    .p-awesomepage
      header.p-a-header wow, header!
      .p-a-content
        .p-a-title my awesome title

      .p-a-loading
        .p-a-title loading is being loaded
  

Если сочетать такой подход с вложенностью правил в Stylus (главное не перевкладывать, а то получится колбаса на выходе), то пересечений быть не должно.

Про Jade особо рассказывать нечего. Вполне себе шаблонизатор. Кто использовал Slim – то же самое. Поддерживает include и partial. Хелперы я просто прокинул через общий helpers.js файл. Хотя можно регистрировать свои. Например, уже есть :markdown.

Немножко про деплои

Если про то, как разрабатывать разработку, все понятно практически сразу, то вот как деплоить деплои приходится немного задуматься. У рельсовиков уже придумано 9999999 решений вроде capistrano, а для ноды пока-что ничего работоспособного мне найти не удалось.
Я решил на тестовый VPS выкладываться просто cake инструкцией: `cake deploy`, которая делает буквально следующее:

    task 'update', '[VPS]: Update current state with new from repo', ->
      console.log('[Cake]: Pulling updates from repo')
      exec('git pull', (error, stdout, stderr) ->
        unless error
          console.log('[Cake]: Installing npm packages')
          exec('npm install', (error, stdout, stderr) ->
            unless error
              console.log('[Cake]: Restarting forever')
              # exec('forever restartall')
              exec('killall forever')
              exec('killall nodejs')

              console.log('[Cake]: Cleaning up old assets')
              exec('find ./public/assets -regextype posix-egrep -regex ".*.(js|css|gz|gzip)$" -delete')

              exec('cake forever')
              sendMail()
            else
              console.warn("[Cake]: Installation failed with error: #{error}")
          )
        else
          console.warn("[Cake]: Update failed with error: #{error}")
      )
    task 'deploy', '[DEV]: Deploy current repo state to dev VPS', ->
      console.log('[Cake]: Connecting to VPS mobota@mobota-dev.ostrovok.ru && running update')
      exec('ssh mobota@mobota-dev.ostrovok.ru 'cd /var/www/mobota && cake update'', (error, stdout, stderr) ->
        if error
          console.warn("[Cake]: Deploy failed with error: #{error}")
        else
          console.log('[Cake]: Deployed!')
      )
  

Для выкатывания на production Денис Орлихин наладил собиралку debian пакетов. Работает череp teamcity, прогоняет тесты перед сборкой. Очень прикольно. Детали не расскажу, но может он как-нибудь расскажет.

Кстати о тестах. Я обычно тесты не пишу, потому что хаха-фронтенд и в принципе очень ленивый. Но тут integration tests действительно понадобились, так как приходится зависеть от стороннего API. Я писал тесты на mocha + chaiJS. Очень просто и удобно, а главное хороший reporter:
Чем живет мобильный Островок
(Сижу в парке, поэтому интернет не супер быстрый и тесты свалились)

Это все очень интересно, но где тут про мобильную разработку.

И действительно, пора бы уже про самое главное. Какие отличия я заметил при разработке под мобильные устройства в сравнении с полноценными браузерами. Учитывая, что приходится работать под несколько платформ сразу, а у каждой платформы свои проблемы, получается не так уж и просто. Могу сказать что, ситуация с прошлого года изменилась не сильно. Главная проблема это Android телефоны 2-й версии, и ранние 4-ки. С iOS все куда проще, хотя и у них бывают чудеса.

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

  • Тормоза при кликах. Все знают про 300ms задержку при кликах. Любые клики по обычным ссылкам на сайте будут обрабатываться примерно через 300ms после нажатия. Это нужно, чтобы понять, что это действительно клик, а не какой-то другой жест. В качестве бонуса, если попробовать на ссылку добавить touchstart event listener, мы увидим т.н. ghost click в этой же области ровно через 300ms, даже если содержимое страницы уже поменялось. А на Android 2 бывает даже на совершенно другой странице. Решений проблемы несколько. Можно придумывать хитроумные схемы по отлавливанию этих ghost-click, как это делают в Google. А можно просто отказаться от ссылок и click event в принципе. В моем случае я выбрал второй вариант. В Zepto есть кастомный tap event как раз для этого, только вот он сломан. Я написал свое решение, и, наверное, выложу его на GitHub.
  • Размытые картинки на Retina. Сейчас все больше устройств с pixel-ratio > 1.5, что значит, что на один виртуальный пиксел приходится несколько физических. Чтобы картинки не получались на таких устройствах размазанными достаточно загружать для них изображения в несколько раз больше и масштабировать. Подход для ленивых — всегда загружать большие картинки и масштабировать (background-size FTW).
  • Hardware-accelerated animations при помощи transition/transform. Вроде бы тут все очевидно, используй везде translate или даже translate3d и будет тебе счастье. На деле выходит не так. Во первых, не все свойства будут ускорены при помощи GPU, их всего несколько. Позиция, прозрачность и т.п. Во вторых у Android 2 серьезные проблемы с translate3d в виде периодического мерцания анимированного блока, появления «елочек», и вообще тормозов. Ну а у iOS есть проблема в случае, если нужно сделать re-render блока, который находится на GPU слое. К примеру, на моем сайте есть sidebar, и его можно таскать, а так же он анимированно выезжает/уезжает по клику. Прикольная штука, мало где есть, а с анимацией я вообще нигде не видел. Но проблема в том, что если контент перенести на GPU, то при переходах на некоторые страницы он начинает мерцать, пропадать с экрана на полсекунды, пока браузер его не отрисует и снова не скинет на GPU. Пришлось вернуться к использованию left/top. Помимо этого, если показывать нативные select элементы на блоке, который вытащили откуда-то c помощью translate, то на Android 2 работать они полноценно не будут. Имейте в виду (выпадающий список показывается там, где блок был изначально, а не там, где он сайчас на экране).
  • Fixed positioning. Я бы даже не стал пробовать. С overflow:scroll вроде ситуация стала получше, так что решать проблему фиксированных элементов надо хитрыми layout. Когда я смотрел в последний раз, перерисовка при открытой клавиатуре на iOS так толком и не работала.
  • Android 2 просто чудесно работает с JSON.parse(null). Старайтесь валидировать строку перед парсингом. А еще он не умеет полноценно отрисовывать текст с text-shadow. Могут не работать переносы, улетать некоторые буквы и вообще происходить что угодно. Я всегда делаю:
            .android2 *
              text-shadow: none !important; 

    Экономит много времени и нервов.

  • В том как ведет себя window есть некоторые отличия. Во первых можно очень легко менять масштаб, что на больших браузерах почти никогда не делают. Я советую масштаб залочить, жить станет проще. Лочится так:
            meta(name="viewport", content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no")
          

    Еще нужно у mobile html5boilerplate подсмотреть метод preventZoom(). Очень помогает. Да и вообще, оттуда много классных вещей можно взять.

    С определением высоты/ширины экрана могут быть проблемы. Android/iOS неконсистентны в своих ответах. Android, в частности, вообще может сказать, что он в 2 раза шире и выше, чем на самом деле. Я предпочитаю по возможности использовать CSS, и вам советую.Но если все-таки очень хочется, у меня получилось вот так:

            class Size
              _min_width: 320
              _min_height: 416
    
              _iOS_toolbar_height: 44
              _android_delay: 300
    
              constructor: ->
                @width = null
                height = null
    
                if $.os.ios
                  app.dom.win.on('orientationchange load', @orientationChange)
                else
                  app.dom.win.on('orientationchange resize load', =>
                    window.setTimeout(@orientationChange, @_android_delay)
                  )
    
                @orientationChange()
    
              orientationChange: =>
                prevWidth = @width
                prevHeight = height
    
                if $.os.ios and not window.orientation
                  @width = window.screen.availWidth
                  height = window.screen.availHeight - @_iOS_toolbar_height
                else
                  @width = app.dom.win.width()
                  height = app.dom.win.height()
    
                @width = Math.max(@_min_width, @width)
                height = Math.max(@_min_height, height)
    
                if @width isnt prevWidth or height isnt prevHeight
                  app.trigger('size:resize',
                    width: @width
                    height: height
                    )  

    С определением высоты контента/окна ожидайте много приключений, мобильные браузеры в этом плане очень классные и нормально сделать что-то на полный экран и с overlay/modal будет непросто.

  • Сетевые запросы очень медленные, особенно если долгое время ничего не запрашивалось. Параллелить запросы тоже может оказаться не очень хорошей затеей, один из них может запросто сфейлиться, да и как оказалось это вовсе не быстрее. Я стараюсь делать мало запросов с большими ответами. (большие это 500кб-1мб максимум, все-таки мобильная связь). Кажется, так работает лучше. С операциями с DOM — та же история. Все очень медленно, потому что аппарат очень тупой. Особенно Android, я не понимаю в чем у них проблема. Даже новые 4-ки проигрывают тому же iPhone 4S. В общем, тут работают обычные правила – меньше запросов и группировать запись/чтение.
  • Ранние версии Android 4 не поддерживают History API. Это значит, что в Backbone придется использовать хеши вместо нормального обновления state. Новые версии уже должны поддерживать все как и надо, а Android 2 всегда поддерживал, поэтому я просто не стал заморачиваться.
  • Если у вас страница отскроллена вниз и вы меняете контент – Android не будет возвращать скролл позицию в исходное положение. В зависимости от высоты нового контента могут быть видимые проблемы.
  • Если у вас есть блок с position: absolute, который улетел куда-то за пределы экрана, убедитесь, что он не очень большой по высоте. Иначе на странице появятся «дырки» как будто он все равно занимает какую-то высоту. Актуально для iOS.
  • Если хочется сделать что-то на полный экран в iOS, то нужно задать height одному из корневых элементов, равный фактической высоте экрана. Иначе статус бар никуда не пропадет как ни старайся. height: 100% тоже не сработает как надо.
  • Если у родительского блока есть overflow: scroll, а у дочерних position: absolute, могут быть проблемы при отрисовке на Android. Решается только отключением overflow: scroll.

В целом, подход к разработке похож на работу с большими сайтами на очень слабых машинах. Нужно не забывать, что батарейка и ресурсы процессора/памяти очень ограничены, и всегда проверять производительность на реальных устройствах. Конечно, это только мой личный опыт, основанный на устройствах, которые были доступны для тестирования. Я могу ошибаться по некоторым пунктам.

Бонусы разработки под мобильные, в отличие от десктопных, браузеры:

  • Хоть устройства и тормозные, но все-таки современные и много что поддерживается. Это значит анимации через keyframes/transition и современный JavaScript etc.
  • Всего 3 платформы. iOS вполне неплохо работает сама по себе, а для отладки у нас есть Developer Tools в Safari, советую использовать. С Android похуже, мало какие отдают browser log в adb консоль, но все равно их всего 2 платформы и схожий движок браузера. Можно забыть про многие vendor prefixes.
  • Всегда интересно поиграть с новыми игрушками. А еще мобильные сайты можно заворачивать как native apps, или хотя-бы добавлять на homescreen. Классно.

Заключение

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

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

Автор: Ostrovok

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js