Статьи Архив статей

Автор: webdew
Опубликована: 7 февраля 2009, habrahabr.ru/blogs/client_side_optimization/51258/

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

Все мы знаем сотню способов загрузки скриптов. У каждого свои плюсы и минусы.

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

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

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

Бонус: индикатор загрузки

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

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

UPD 2. Для тех кто не дочитывает до конца (я вас прекрасно понимаю), в концовке сказано, как все можно сделать намного проще.

Вместо core.633675510761.js писать core.js И там же указано, почему все же написано так много.

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

Под модульностью я понимаю, что каждый компонент системы расположен в отдельном файле: core.js, utils.js, control.js, button.js и т. д.

От этого принципа я не отказываюсь даже при загрузке страницы. Хотя я знаю, что загрузка 1 файла 100 Кб быстрее, чем 10 по 10 Кб.

Эту проблему я решу через кэширование далее.

Быстрота загрузки — это всевозможные ухищрения для максимально быстрого отображения страницы.

Способ объединения скриптов в пакеты я отмел выше. Главными минусами его считаю:

  • изменение в одном компоненте приводит к необходимости перегрузке всего пакета;
  • высока вероятность дублирования компонентов в разных пакетах.
  • Поэтому остается минимизация, сжатие и кэширование.

    От сжатия я также отказался, т. к. есть мнения, что выигрыш в скорости загрузки файла теряется в скорости его распаковки (прим. от sunnybear: утверждение аргументировано недостаточно, можно взглянуть на исследование загрузки процессора при сжатии и оценку скорости загрузки JS-библиотек).

    Кэширование. Вот тут появляется изюминка моего способа.

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

    Теперь почему я так смело поступаю и уверен что мой файл будет год валидным.

    Потому что я приписываю к имени файла дату его последней модификации!

    Т.е. есть core.js, включение происходит так

    <script src="/Scripts/core.633675510761.js" type=«text/javascript»></script>

    Все, следующее изменение в этом файле изменит его имя на 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 );
          }
      }

      Комментарий:

      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 ) );
      	}
          }
      }

      Вызов в global.asax

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

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

    2. Следующий пункт, это обработчик 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; } }
          }

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

    Возможно вы скажете, много сложностей с реализацией отдельного хэндлера.

    Могу посоветовать более простой способ обработки файла. Вместо <script src="/Scripts/core.633675510761.js" type=«text/javascript»></script> писать <script src="/Scripts/core.js" type=«text/javascript»></script>

    Тогда не нужен 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;
    		}
    	    }

    Рисуется два 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; }

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

    Читать дальше

    Все комментарии (habrahabr.ru)