Pull to refresh

Загрузка JavaScript-файлов. Решаем проблему Ctrl-F5

Reading time 15 min
Views 27K
Все мы знаем сотню способов загрузки скриптов. У каждого свои плюсы и минусы.

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

Цели: модульность разработки, быстрота загрузки, валидный кэш.
Бонус: индикатор загрузки

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

UPD 2. Для тех кто не дочитывает до конца (я вас прекрасно понимаю), в концовке сказано, как всё можно сделать намного проще.
Вместо core.633675510761.js писать core.js?v=633675510761. И там же указано, почему всё же написано так много.

UPD 3. В комментариях от david_mz, WebByte прозвучало предложение для обработки запроса использовать не JSHandler, а urlrewrite.



Под модульностью я понимаю, что каждый компонент системы расположен в отдельном файле: core.js, utils.js, control.js, button.js и т.д.
От этого принципа я не отказываюсь даже при загрузке страницы. Хотя я знаю, что загрузка 1 файла 100Кб быстрее 10-ти по 10Кб.
Эту проблему я решу через кэширование далее.

Быстрота загрузки — это всевозможные ухищрения для максимально быстрого отображения страницы.
Способ объединения скриптов в пакеты я отмёл выше. Главными минусами его считаю:
  • изменение в одном компоненте приводит к необходимости перегрузке всего пакета
  • высока вероятность дублирования компонентов в разных пакетах

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

Помимо использования заголовков «If-Modified-Since» и «If-None-Match» (ETag) я устанавливаю Expires через год!
Теперь почему я так смело поступаю и уверен что мой файл будет год валидным.

Потому что я приписываю к имени файла дату его последней модификации!
Т.е. есть core.js, включение происходит так


Всё, следующее изменение в этом файле изменит его имя на core.635675530761.js, и будет подгружен совершенно новый скрипт.
Теперь я перечислю плюсы этого способа, они сразу не очевидны:
  • мы получаем надежный кэш файла. Даже оперируя заголовками Last-Modified и ETag, мы не всегда получаем последнюю версию файла. Браузер или прокси-сервер, не всегда запрашивают информацию о файле. Часто они берут свой кэш. В таких случаях обычное явление сброс кэша по Ctrl-F5. Знакомо? Теперь же, взять из внутреннего кэша старый файл не возможно, т.к. мы запрашиваем фактически новый файл с новым именем.
  • пропадают все запросы на проверку If-None-Match и If-Modified-Since. Даже если файл не изменился и сервер возращает Not Modified 304, всё равно каждый файл — это новый запрос — это задержка. Теперь повторные обращения к одному файлу сразу берут его из кэша. Здорово, не правда ли?


Для наглядности приведу пару скриншотов. Интернет очень медленный.
Первое обращение к странице.

Второе:

Изменяем один файл — третье обращение


Как видите из второго и третьего рисунков, браузер обновил изменённый скрипт. Также видно, что он не удосужился проверить все файлы на наличие изменений. Т.е. на странице много картинок, а он почему-то проверил только две. Тоже самое происходит со скриптами. Они обновляются не всегда. Web-сервер может устанавить дополнительные заголовки для статических файлов, вроде Set-Expires + (1-9999) минут. Плюс внутреняя логика браузера и proxy-серверов. Вообщем, на что мы повлияеть не можем.

Это была теория. Реализовать на практике это не составляет никакого труда.
Я приведу пример как я решаю это на ASP.NET. Поэтапно.

1. Для включения файлов на страницу я использую специальный объект, который проверяет на уникальность включаемого файла. А затем при рендинге пишет мои файлы с префиком даты.

public class ScriptHelper
{
  protected StringCollection includeScripts = new StringCollection();

  public void Include( String filename )
  {
    filename = filename.ToLower();
    StringCollection container;
    switch( System.IO.Path.GetExtension( filename ) )
    {
      case ".js": container = includeScripts; break;
      default: throw new ArgumentException( "Not supported include file: " + filename, "filename" );
    }
    if( !container.Contains( filename ) ) container.Add( filename );
  }

  public void RegisterScripts( Page page )
  {
    StringBuilder clientScript = new StringBuilder();
    foreach( String filename in includeScripts )
      clientScript.AppendFormat( includeJS, prefix + FileSystemWatcherManager.GetModifiedName( "Scripts/" + filename ) );
    page.ClientScript.RegisterClientScriptBlock( page.GetType(), "clientscripts", clientScript.ToString(), false );
  }
}


* This source code was highlighted with Source Code Highlighter.


Комментарий:
prefix — префикс относительного пути папки со скриптами
FileSystemWatcherManager — менеджер по работе с физическими файлами. Этот класс позволяет избегать частых вызовов System.IO.File.GetLastWriteTimeUtc(), и является простой оболочкой монитора файловой системы. Позволю себе привести полный код.

using System;
using System.IO;
using System.Collections.Generic;

public class FileSystemWatcherManager
{
  private static String physicalAppPath;
  private static SortedList<String, Int64> lastModifiedFiles = new SortedList<String, Int64>();

  public static void StartDirectoryWatcher( String directory, String filter )
  {
#if DEBUG
    return;
#endif
    if( physicalAppPath == null && System.Web.HttpContext.Current.Request != null )
      physicalAppPath = System.Web.HttpContext.Current.Request.PhysicalApplicationPath;

    foreach( String pattern in filter.Split( ',' ) )
    {
      FileSystemWatcher dirWatcher = new FileSystemWatcher( directory, pattern );
      dirWatcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite;
      dirWatcher.IncludeSubdirectories = true;
      dirWatcher.EnableRaisingEvents = true;
      dirWatcher.Changed += new FileSystemEventHandler( OnFileSystemChanged );
      dirWatcher.Created += new FileSystemEventHandler( OnFileSystemChanged );
      dirWatcher.Renamed += new RenamedEventHandler( OnFileSystemRenamed );

      UpdateLastModifiedFiles( directory, pattern, true );
    }
  }

  private static void OnFileSystemRenamed( object sender, RenamedEventArgs e )
  {
    UpdateLastModifiedFiles( Path.GetDirectoryName( e.FullPath ), ( (FileSystemWatcher)sender ).Filter, true );
  }

  private static void OnFileSystemChanged( object sender, FileSystemEventArgs e )
  {
    UpdateLastModifiedFiles( Path.GetDirectoryName( e.FullPath ), ((FileSystemWatcher)sender).Filter, true );
  }

  public static void UpdateLastModifiedFiles( String directory, String filter, Boolean logAction )
  {
    lock( lastModifiedFiles )
    {
      if( logAction )  WL.Logger.Instance.Log( String.Format( "Update modified files {1} at \"{0}\"", directory, filter ) );

      foreach( String subDir in Directory.GetDirectories( directory ) )
        UpdateLastModifiedFiles( subDir, filter, false );
      foreach( String file in Directory.GetFiles( directory, filter ) )
        lastModifiedFiles[file.Substring( physicalAppPath.Length ).ToLower().Replace( '\\', '/' )] = File.GetLastWriteTimeUtc( file ).Ticks / 1000000;
    }
  }

  public static String GetModifiedName( String clientPath )
  {
#if DEBUG
    return clientPath;
#endif
    lock( lastModifiedFiles )
    {
      Int64 ticks;
      if( !lastModifiedFiles.TryGetValue( clientPath.ToLower(), out ticks ) ) return clientPath;
      return String.Format( "{0}/{1}.{2}{3}", Path.GetDirectoryName( clientPath ).Replace( '\\', '/' ), Path.GetFileNameWithoutExtension( clientPath ), ticks, Path.GetExtension( clientPath ) );
    }
  }
}


* This source code was highlighted with Source Code Highlighter.


Вызов в global.asax

void Application_Start( object sender, EventArgs e )
{
  FileSystemWatcherManager.StartDirectoryWatcher( HttpContext.Current.Request.PhysicalApplicationPath, "*.js,*.css" );
}


* This source code was highlighted with Source Code Highlighter.


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

Следующий пункт, это обработчик js-файлов.
Включается через web.config
<httpHandlers>
<add verb="GET" path="*.js" type="WL.JSHandler"/>
</httpHandlers>


Обработчик нужен чтобы удалить префикс со временем изменения и отдать реальный файл. Также он проверяет заголовки If-None-Match, If-Modified-Since, устанавливает LastModified, ETag и Expires. Также возможен выбор файла, оригинальный, минимизированый, сжатый, проверка прав и прочее.

Привожу облечённую версию.
public class JSHandler : IHttpHandler
  {
    public void ProcessRequest( HttpContext context )
    {
      try
      {
        String filepath = context.Request.PhysicalPath;
        String[] parts = filepath.Split( '.' );
        Int64 modifiedTicks = 0;
        if( parts.Length >= 2 )
        {
          if( Int64.TryParse( parts[parts.Length - 2], out modifiedTicks ) )
          {
            List<String> parts2 = new List<String>( parts );
            parts2.RemoveAt( parts2.Count - 2 );
            filepath = String.Join( ".", parts2.ToArray() );
          }
        }

        FileInfo fileInfo = new FileInfo( filepath );
        if( !fileInfo.Exists )
        {
          context.Response.StatusCode = 404;
          context.Response.StatusDescription = "Not found";
        }        
        else
        {
          DateTime lastModTime = new DateTime( fileInfo.LastWriteTime.Year, fileInfo.LastWriteTime.Month, fileInfo.LastWriteTime.Day, fileInfo.LastWriteTime.Hour, fileInfo.LastWriteTime.Minute, fileInfo.LastWriteTime.Second, 0 ).ToUniversalTime();
          String ETag = String.Format( "\"{0}\"", lastModTime.ToFileTime().ToString( "X8", System.Globalization.CultureInfo.InvariantCulture ) );
          if( ETag == context.Request.Headers["If-None-Match"] )
          {
            context.Response.StatusCode = 304;
            context.Response.StatusDescription = "Not Modified";
          }
          else
          if( context.Request.Headers["If-Modified-Since"] != null )
          {
            String modifiedSince = context.Request.Headers["If-Modified-Since"];
            Int32 sepIndex = modifiedSince.IndexOf( ';' );
            if( sepIndex > 0 ) modifiedSince = modifiedSince.Substring( 0, sepIndex );
            DateTime sinceDate;
            if( DateTime.TryParseExact( modifiedSince, "R", null, System.Globalization.DateTimeStyles.AssumeUniversal, out sinceDate ) &&
              lastModTime.CompareTo( sinceDate.ToUniversalTime() ) == 0 )
            {
              context.Response.StatusCode = 304;
              context.Response.StatusDescription = "Not Modified";
            }
          }
          if( context.Response.StatusCode != 304 )
          {
            String file = fileInfo.FullName;

          /*  String encoding = context.Request.Headers["Accept-Encoding"];
            if( encoding != null && encoding.IndexOf( "gzip", StringComparison.InvariantCultureIgnoreCase ) >= 0 &&
              File.Exists( file + ".jsgz" ) )
            {
              file = file + ".jsgz";
              context.Response.AppendHeader( "Content-Encoding", "gzip" );
            }
            else*/
            if( File.Exists( file + ".jsmin" ) ) file = file + ".jsmin";
  
            if( context.Request.HttpMethod == "GET" )
            {
              context.Response.TransmitFile( file );
            }

            context.Response.Cache.SetCacheability( HttpCacheability.Public );
            context.Response.Cache.SetLastModified( lastModTime );
            context.Response.Cache.SetETag( ETag );
            if( modifiedTicks != 0 )
              context.Response.Cache.SetExpires( DateTime.UtcNow.AddYears( 1 ) );
            context.Response.AppendHeader( "Content-Type", "text/javascript" );

            context.Response.StatusCode = 200;
            context.Response.StatusDescription = "OK";
          }
        }
      }
      catch( Exception ex )
      {
        WL.Logger.Instance.Error( ex );
        context.Response.StatusCode = 500;
        context.Response.StatusDescription = "Internal Server Error";
      }
    }

    public bool IsReusable { get { return true; } }
  }


* This source code was highlighted with Source Code Highlighter.


Замечание. Если заметили я отдаю либо оригинальный файл, либо .jsmin, либо .jsgz.
Это минимизированная и сжатая версии, которые строятся автоматически отдельной тулзой при билде сервера. Чтобы запретить прямой доступ к ним надо добавить в web.config


<httpHandlers>
<add verb="*" path=".jsmin" type="System.Web.HttpForbiddenHandler"/>
<add verb="*" path=".jsgz" type="System.Web.HttpForbiddenHandler"/>
</httpHandlers>


Вместо концовки


Возможно вы скажете, много сложностей с реализацией отдельного хэндлера.
Могу посоветовать более простой способ обработки файла. Вместо писать
Тогда не нужен JSHandler. Но я не уверен как будет работать кэш. Фактически имя файла не меняется, а появляется только дополнительный параметр. Т.е. может возникнуть та же проблема с Ctrl-F5 из-за внутренного кэша браузера или прокси-сервера.
Но мне отдельный JSHandler нужен ещё для проверки прав на доступ к скриптам, например из папки Admin отдаю только админам.
Плюс ко всему кому-то будет полезно и интересно посмотреть реализации JSHander'а и монитора файловой системы.

Если задача JSHandler только в отрезании ключа модификации, то его можно заменить urlrewrite-модулем.

Очевидно, что таким способом можно грузить другие типы файлов, например .css. Я так и делаю, это видно на первом скриншоте, я специально навёл на css-файл курсор.
Можно расширить и на другие типы, например картинки. Но это нецелесообразно. Во-первых, замучаетесь в коде проставлять нужные имена, а во-вторых, картинки меняются крайне редко, поэтому если какая-то и застрянет в кэша браузера, то не страшно. А если страшно, переименуйте эту картинку вручную.

И обещанный бонус с индикатором загрузки.


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

StringBuilder clientScript = new StringBuilder();
      if( includeScripts.Count > 0 )
      {
        clientScript.Append( @"<div id=""preloader"" style=""display:none""><div></div>Loading Scripts...</div>" );        
      }
      clientScript.Append( scriptStart );
      if( includeScripts.Count > 0 )
      {
        clientScript.Append( @"var pl=document.getElementById(""preloader"");pl.style.display="""";pl=pl.firstChild;" );
      }      
      clientScript.Append( scriptEnd );
      if( includeScripts.Count > 0 )
      {
        Single dx = 100f / includeScripts.Count;
        Single pos = 0f;
        foreach( String filename in includeScripts )
        {
          clientScript.AppendFormat( includeJS, prefix + FileSystemWatcherManager.GetModifiedName( "Scripts/" + filename ) );
          clientScript.AppendFormat( @"<script type=""text/javascript"">pl.style.width=""{0}%"";</script>", (Int32)pos );
          pos += dx;
        }
      }


* This source code was highlighted with Source Code Highlighter.


Рисуется два div'а. И второму по мере загрузки наращивается ширина.
В CSS это выглядит вот так:
#preloader { width:218px;height:92px;background:transparent url("../images/preload.jpg") no-repeat scroll left top;position:relative;text-align:right;color:#383922;font-weight:bold;margin-left:20px;margin-right:auto; }
#preloader div { width:0px;height:92px;background:transparent url("../images/preload.jpg") no-repeat scroll left bottom;position:absolute;left:0px;top:0px; }


Спасибо за внимание. Готов выслушать критику, замечания и ответить на вопросы

Tags:
Hubs:
+41
Comments 87
Comments Comments 87

Articles