Ctrl-F5
Все мы знаем сотню способов загрузки скриптов. У каждого свои плюсы и минусы.
Хочу представить вам очередной метод загрузки js-файлов. Я также понимаю, что такой метод активно используется в сети, но статей про него я не видел.
Поэтому опишу способ, которым пользуюсь сам, в надежде, что он вам тоже понравится.
Цели: модульность разработки, быстрота загрузки, валидный кэш.
Бонус: индикатор загрузки
UPD. Обозначил главную цель этого метода — валидный кэш.
При использовании данного метода, у вас не будет неуверенности в том, обновится ли скрипт и будет ли он работать у конечного пользователя.
UPD 2. Для тех кто не дочитывает до конца (я вас прекрасно понимаю), в концовке сказано, как все можно сделать намного проще.
Вместо core.633675510761.js
писать core.js И там же указано, почему все же написано так много.
UPD 3. В комментариях от
Под модульностью я понимаю, что каждый компонент системы расположен в отдельном файле: 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. Поэтапно.
Для включения файлов на страницу я использую специальный объект, который проверяет на уникальность включаемого файла. А затем при рендинге пишет мои файлы с префиком даты.
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
режимом, я использую реальные имена файлов, чтобы дебаггер мог их цеплять.
Следующий пункт, это обработчик 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; }
Спасибо за внимание. Готов выслушать критику, замечания и ответить на вопросы.