Все мы знаем сотню способов загрузки скриптов. У каждого свои плюсы и минусы.
Хочу представить вам очередной метод загрузки 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, и будет подгружен совершенно новый скрипт.
Теперь я перечислю плюсы этого способа, они сразу не очевидны:
Для наглядности приведу пару скриншотов. Интернет очень медленный.
Первое обращение к странице.

Второе:

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

Как видите из второго и третьего рисунков, браузер обновил изменённый скрипт. Также видно, что он не удосужился проверить все файлы на наличие изменений. Т.е. на странице много картинок, а он почему-то проверил только две. Тоже самое происходит со скриптами. Они обновляются не всегда. Web-сервер может устанавить дополнительные заголовки для статических файлов, вроде Set-Expires + (1-9999) минут. Плюс внутреняя логика браузера и proxy-серверов. Вообщем, на что мы повлияеть не можем.
Это была теория. Реализовать на практике это не составляет никакого труда.
Я приведу пример как я решаю это на ASP.NET. Поэтапно.
1. Для включения файлов на страницу я использую специальный объект, который проверяет на уникальность включаемого файла. А затем при рендинге пишет мои файлы с префиком даты.
Комментарий:
prefix — префикс относительного пути папки со скриптами
FileSystemWatcherManager — менеджер по работе с физическими файлами. Этот класс позволяет избегать частых вызовов System.IO.File.GetLastWriteTimeUtc(), и является простой оболочкой монитора файловой системы. Позволю себе привести полный код.
Вызов в global.asax
Думаю, комментарии излишни, единственное отмечу, под DEBUG режимом, я использую реальные имена файлов, чтобы дебаггер мог их цеплять.
Следующий пункт, это обработчик js-файлов.
Включается через web.config
Обработчик нужен чтобы удалить префикс со временем изменения и отдать реальный файл. Также он проверяет заголовки If-None-Match, If-Modified-Since, устанавливает LastModified, ETag и Expires. Также возможен выбор файла, оригинальный, минимизированый, сжатый, проверка прав и прочее.
Привожу облечённую версию.
Замечание. Если заметили я отдаю либо оригинальный файл, либо .jsmin, либо .jsgz.
Это минимизированная и сжатая версии, которые строятся автоматически отдельной тулзой при билде сервера. Чтобы запретить прямой доступ к ним надо добавить в web.config
Возможно вы скажете, много сложностей с реализацией отдельного хэндлера.
Могу посоветовать более простой способ обработки файла. Вместо писать
Тогда не нужен JSHandler. Но я не уверен как будет работать кэш. Фактически имя файла не меняется, а появляется только дополнительный параметр. Т.е. может возникнуть та же проблема с Ctrl-F5 из-за внутренного кэша браузера или прокси-сервера.
Но мне отдельный JSHandler нужен ещё для проверки прав на доступ к скриптам, например из папки Admin отдаю только админам.
Плюс ко всему кому-то будет полезно и интересно посмотреть реализации JSHander'а и монитора файловой системы.
Если задача JSHandler только в отрезании ключа модификации, то его можно заменить urlrewrite-модулем.
Очевидно, что таким способом можно грузить другие типы файлов, например .css. Я так и делаю, это видно на первом скриншоте, я специально навёл на css-файл курсор.
Можно расширить и на другие типы, например картинки. Но это нецелесообразно. Во-первых, замучаетесь в коде проставлять нужные имена, а во-вторых, картинки меняются крайне редко, поэтому если какая-то и застрянет в кэша браузера, то не страшно. А если страшно, переименуйте эту картинку вручную.
Пока грузятся ваши скрипты первый раз, визуально ускорить процесс можно показав процесс загрузки.
Помните место где я пишу включение файлов? Там на самом деле код такой:
Рисуется два div'а. И второму по мере загрузки наращивается ширина.
В CSS это выглядит вот так:
Хочу представить вам очередной метод загрузки 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; }