网站优化之压缩页面内嵌的js代码

1,553 views

背景

书接网站优化之压缩页面输出,使用文中的方法对当前项目的页面执行了压缩,初步看效果还不错,使用浏览器来观察各个页面的下载量时,都有不同程度的下降。但进一步分析页面代码的特点,感觉还有进一步压缩的空间。

当前项目中,jsp页面上的js代码量很大,如果只使用网站优化之压缩页面输出中介绍的方法去除掉页面中多余的空格和换行,感觉对压缩率贡献不够大。使用浏览器来查看业界标杆淘宝首页的源码,可以发现淘宝的开发人员对web页面上的js代码做了压缩和混淆。

既然淘宝的开发人员可以做到,那说明压缩页面上出现的js代码并不是一件困难的事情。

实现方案

网站优化之压缩页面输出中,使用了Ant名为ReplaceRegExp的Task,实现从jsp页面代码中删除多余的空格和换行,那么是否可以使用类似的方法把js代码提取出来,压缩之后替换回原页面代码呢?

方案分析

在项目组的jsp页面中,抽查几个jsp源码文件,发现页面上插入js代码有几种模式:

  • 引用外部js文件
    <script src="<%=request.getContextPath()%>/3rd/jquery/jquery.min.js"></script>
    
  • 嵌入js代码,样式1
    <script type="text/javascript">     
    var COUNT_MAX = 60, counter = COUNT_MAX;
    ......
    </script>
    
  • 嵌入js代码,样式2
    <script>        
    var COUNT_MAX = 60, counter = COUNT_MAX;
    ......
    </script>
    

显然,第1种样式不需要考虑,我们只需要处理其余两种即可。

接下来的要做的事情即是参考ReplaceRegExp的源码,自定义一个Task,达成如下目标:

  • 提取jsp页面中出现的js代码。
  • 对提取出来的js代码执行压缩。
  • 将压缩过的js代码写回原jsp页面。

提取jsp页面上的js代码

最直接的方法即是按照前述规律书写正则表达式,使用Java的正则表达式API从jsp页面中提取js代码。

书写正确的正则表达式并不容易,比如如下的正则表达式由于过于贪婪,并不能解决问题。

  • <script>.+</script>
  • <script([\s]+type[\s]*=[\s]*\"text/javascript\"[\s]*)?>.+</script>

这时要注意使用正则表达式的懒惰模式,避免中间的.+匹配过多的内容,因此正确的写法为.+?,完整的正则表达式如下:

<script([\s]+type[\s]*=[\s]*\"text/javascript\"[\s]*)?>.+?</script>

另外注意在构造java.util.regx.Pattern类型的对象时,要记得传入选项Pattern.DOTALL,允许.匹配换行符。

压缩js代码

之前在使用YUICompressor来压缩js和css文件时,简单的看过YUICompressor工具的源码,发现作者针对js和css的压缩操作分别提供了实现类,如com.yahoo.platform.yui.compressor.JavaScriptCompressor是js代码的压缩处理类。

恰好可以为我所用,解决当前遇到的问题。

但YUICompressor工具直接处理的是保存在文件里的js代码,和当前我要处理的场景稍有不同;使用正则表达式从jsp页面代码中提取出来的js显然是保存在内存里的,没必要写到文件里后再做压缩处理。

检查前述的JavaScriptCompressor类的源码,发现构造方法中允许传入java.io.Reader类的实例,而实际执行压缩操作的方法compress可以传入java.io.Writer类的实例。那么借助于java.io.StringReaderjava.io.StringWriter,就可以实现在内存中处理js代码了。

相关代码片断如下:

JavaScriptCompressor compressor = new JavaScriptCompressor(reader, new ErrorReporter() {

    public void warning(String message, String sourceName,
            int line, String lineSource, int lineOffset) {

        if (line < 0) {
            log("  " + message, Project.MSG_WARN);
        } else {
            log("  " + line + ':' + lineOffset + ':' + message + ':' + lineSource, Project.MSG_WARN);
        }
    }

    public void error(String message, String sourceName,
            int line, String lineSource, int lineOffset) {

        if (line < 0) {
            log("  " + message, Project.MSG_ERR);
        } else {
            log("  " + line + ':' + lineOffset + ':' + message + ':' + lineSource, Project.MSG_ERR);
        }
    }

    public EvaluatorException runtimeError(String message, String sourceName,
            int line, String lineSource, int lineOffset) {
        error(message, sourceName, line, lineSource, lineOffset);
        return new EvaluatorException(message);
    }
});     

自定义Ant任务

从08年开始接触Ant工具,虽然很早就知道Ant提供了自定义Task的特性,但一直都在看猪跑,从来没有亲身使用过。本来以为要花费一点时间阅读文档,熟悉熟悉;但阅读过ReplaceRegExp的实现源码后,发现并没有想像中那么困难,代码相当好理解;另外自定义Ant任务相关的资料非常多,官网的资料非常详细,有问题很快就可以搞定。

相关源码

废话少说,直接把源码贴出来:

package org.apache.tools.ant.taskdefs.optional;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.FileSet;
import org.apache.tools.ant.types.RegularExpression;
import org.apache.tools.ant.types.Resource;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.types.resources.FileProvider;
import org.apache.tools.ant.types.resources.Union;
import org.apache.tools.ant.util.FileUtils;
import org.apache.tools.ant.util.regexp.Regexp;
import org.mozilla.javascript.ErrorReporter;
import org.mozilla.javascript.EvaluatorException;

import com.yahoo.platform.yui.compressor.JavaScriptCompressor;


public class JSCompressorTask extends Task {

    private static final String JS_SCRIPT_PATTERN = "<script([\\s]+type[\\s]*=[\\s]*\"text/javascript\"[\\s]*)?>(.+?)</script>";
    private static final String JS_START_PATTERN = "@@@script_start@@@";
    private static final String JS_END_PATTERN = "@@@script_end@@@";
    private static final String JS_START_TAG = "<script>";
    private static final String JS_END_TAG = "</script>";
    private File file;
    private Union resources;
    private RegularExpression regex;

    private static final FileUtils FILE_UTILS = FileUtils.getFileUtils();

    private boolean preserveLastModified = false;

    private boolean preserveCode = false;

    private String encoding = null;

    public JSCompressorTask() {
        super();
        this.file = null;
        regex = new RegularExpression();
        regex.setPattern(JS_SCRIPT_PATTERN);
    }

    public void setFile(File file) {
        this.file = file;
    }

    public void setMatch(String match) {
        if (regex != null) {
            throw new BuildException("Only one regular expression is allowed");
        }

        regex = new RegularExpression();
        regex.setPattern(match);
    }

    public void setEncoding(String encoding) {
        this.encoding = encoding;
    }

    public void addFileset(FileSet set) {
        addConfigured(set);
    }

    public void addConfigured(ResourceCollection rc) {
        if (!rc.isFilesystemOnly()) {
            throw new BuildException("only filesystem resources are supported");
        }
        if (resources == null) {
            resources = new Union();
        }
        resources.add(rc);
    }

    public void setPreserveLastModified(boolean b) {
        preserveLastModified = b;
    }

    public void setPreserveCode(boolean b) {
        preserveCode = b;
    }

    protected String doReplace(RegularExpression r,
                               String input) {
        String res = input;
        Regexp regexp = r.getRegexp(getProject());

        String pattern = regexp.getPattern();

        Pattern p = Pattern.compile(pattern, Pattern.DOTALL);
        Matcher m = p.matcher(input);
        while (m.find()) {
            String jsCode = m.group(2);

            res = doCompress(m, jsCode);
            m.reset(res);
        }

        if (preserveCode) {
            return input;
        }

        res = res.replaceAll(JS_START_PATTERN, JS_START_TAG);
        res = res.replaceAll(JS_END_PATTERN, JS_END_TAG);
        return res;
    }

    protected String doCompress(Matcher m, String jsCode) {
        try
        {
            Reader reader = new StringReader(jsCode);
            JavaScriptCompressor compressor = new JavaScriptCompressor(reader, new ErrorReporter() {

                public void warning(String message, String sourceName,
                        int line, String lineSource, int lineOffset) {

                    if (line < 0) {
                        log("  " + message, Project.MSG_WARN);
                    } else {
                        log("  " + line + ':' + lineOffset + ':' + message + ':' + lineSource, Project.MSG_WARN);
                    }
                }

                public void error(String message, String sourceName,
                        int line, String lineSource, int lineOffset) {

                    if (line < 0) {
                        log("  " + message, Project.MSG_ERR);
                    } else {
                        log("  " + line + ':' + lineOffset + ':' + message + ':' + lineSource, Project.MSG_ERR);
                    }
                }

                public EvaluatorException runtimeError(String message, String sourceName,
                        int line, String lineSource, int lineOffset) {
                    error(message, sourceName, line, lineSource, lineOffset);
                    return new EvaluatorException(message);
                }
            });         
            StringWriter writer = new StringWriter();
            compressor.compress(writer, 10, true, false, false, false);
            String compressedCode = writer.getBuffer().toString();
            StringBuffer sb = new StringBuffer();
            m.appendReplacement(sb, JS_START_PATTERN + compressedCode + JS_END_PATTERN);
            m.appendTail(sb);

            return sb.toString();
        }
        catch (IOException e) {
            e.printStackTrace();
        }

        return jsCode;
    }

    protected void doReplace(File f)
         throws IOException {
        File temp = FILE_UTILS.createTempFile("replace", ".txt", null, true, true);
        try {
            boolean changes = false;

            InputStream is = new FileInputStream(f);
            try {
                Reader r = encoding != null ? new InputStreamReader(is, encoding) : new InputStreamReader(is);
                OutputStream os = new FileOutputStream(temp);
                try {
                    Writer w = encoding != null ? new OutputStreamWriter(os, encoding) : new OutputStreamWriter(os);

                    log("Replacing pattern '" + regex.getPattern(getProject()) + "'.", Project.MSG_VERBOSE);

                    changes = multilineReplace(r, w);

                    r.close();
                    w.close();

                } finally {
                    os.close();
                }
            } finally {
                is.close();
            }
            if (changes) {
                log("File has changed; saving the updated file", Project.MSG_VERBOSE);
                try {
                    long origLastModified = f.lastModified();
                    FILE_UTILS.rename(temp, f);
                    if (preserveLastModified) {
                        FILE_UTILS.setFileLastModified(f, origLastModified);
                    }
                    temp = null;
                } catch (IOException e) {
                    throw new BuildException("Couldn't rename temporary file "
                                             + temp, e, getLocation());
                }
            } else {
                log("No change made", Project.MSG_DEBUG);
            }
        } finally {
            if (temp != null) {
                temp.delete();
            }
        }
    }

    public void execute() throws BuildException {
        if (regex == null) {
            throw new BuildException("No expression to match.");
        }

        if (file != null && resources != null) {
            throw new BuildException("You cannot supply the 'file' attribute "
                                     + "and resource collections at the same "
                                     + "time.");
        }

        if (file != null && file.exists()) {
            try {
                doReplace(file);
            } catch (IOException e) {
                log("An error occurred processing file: '"
                    + file.getAbsolutePath() + "': " + e.toString(),
                    Project.MSG_ERR);
            }
        } else if (file != null) {
            log("The following file is missing: '"
                + file.getAbsolutePath() + "'", Project.MSG_ERR);
        }

        if (resources != null) {
            for (Resource r : resources) {
                FileProvider fp =
                    r.as(FileProvider.class);
                File f = fp.getFile();

                if (f.exists()) {
                    try {
                        doReplace(f);
                    } catch (Exception e) {
                        log("An error occurred processing file: '"
                            + f.getAbsolutePath() + "': " + e.toString(),
                            Project.MSG_ERR);
                    }
                } else {
                    log("The following file is missing: '"
                        + f.getAbsolutePath() + "'", Project.MSG_ERR);
                }
            }
        }
    }

    private boolean multilineReplace(Reader r, Writer w)
        throws IOException {
        return replaceAndWrite(FileUtils.safeReadFully(r), w);
    }

    private boolean replaceAndWrite(String s, Writer w)
        throws IOException {
        String res = doReplace(regex, s);
        w.write(res);
        return !res.equals(s);
    }
}

调试方法

阅读Ant文档时,发现Ant的Task可以在代码中直接使用,于是就有了如下代码,用来调试自定义的Task,借助于eclipse,非常方便。

String f = "index.jsp";
JSCompressorTask task = new JSCompressorTask();
task.setFile(new File(f));
task.setEncoding("UTF-8");
task.setPreserveCode(false);
task.execute();

Ant脚本

脚本是现成的,如下:

<path id="lib.path" >
    <fileset dir="${tools.lib}">
        <include name="yuicompressor-x.y.z.jar"/><!-- yuicompressor工具的jar -->
        <include name="ant-task.jar"/><!-- 包含了JSCompressorTask类的jar -->
    </fileset>
</path>
<target name="compress">
    <taskdef name="jscompress" classname="org.apache.tools.ant.taskdefs.optional.JSCompressorTask" classpath="lib.path"/>
    <jscompress>
        <fileset dir="${build.root}/WebRoot">
            <include name="**/*.jsp"/>
        </fileset>
    </jscompress>
</target>

仔细阅读Ant的文档,发现taskdeftypedef可以说是同义词。

参照typedef的使用文档,发现原来可以不把相关的jar放在%ANT_HOME%/lib目录下,可以在使用taskdef在脚本中加载自定义Task时,指定相关jar的保存路径,这下就相当灵活了。

遇到的问题

使用前述自定义的Task压缩项目组的jsp页面时,发现部分页面会报错;汇总下有如下场景,解决策略一并列出。

js代码中存在jsp取值标签

样例代码

var result;
...
result = <%=request.getParameter("code")%>;

解决策略

在jsp标签前后增加单引号,注意不是双引号。

var result;
...
result = '<%=request.getParameter("code")%>';

上述修改之后,为保证页面可用,可能需要根据jsp标签里提取的内容的类型,增加一些处理:

  • 字符串,无需处理;
  • 数字类型,在使用前需要使用比如parseInt之类的方法做个转换,避免运算报错;
  • json对象,在使用前需要使用jQuery.parseJSON方法将字符串转换为json对象,注意这里不要使用eval,避免引入安全问题;
  • 其它场景,见招拆招。

js代码中存在struts的国际化标签

样例代码如下

    var result = "<s:text name="lable_xxx"></s:text>";

解决策略

从js语法的角度看,类似上述的代码存在双引号嵌套的问题,所以解决方法很简单,同时使用单、双引号。

    var result = '<s:text name="lable_xxx"></s:text>';

js代码中存在struts的逻辑处理标签

样例代码

    <s:if test="%{#user['price']>3000}">
    var result = 1;
    ...
    </s:if>

解决策略

没有特别优雅的解决方法,幸好在我当前的项目里,类似的代码仅在表格的操作列中出现。踌躇好久,只好利用前述正则表达式的缺陷,设计一个规避的方法:

  1. 在jsp页面底部增加一个新的script标签;
  2. 把使用到struts逻辑处理的js代码移动到这个script标签中;
  3. 在script标签中增加自定义的属性,比如optimize="false",让这个标签跳过压缩处理;

代码样例如下:

<script optimize="false">
    <s:if test="%{#user['price']>3000}">
    var result = 1;
    ...
    </s:if>
<script>

参考资料

  • http://ant.apache.org/manual/develop.html
  • http://ant.apache.org/manual/tutorial-HelloWorldWithAnt.html
  • http://ant.apache.org/manual/tutorial-tasks-filesets-properties.html
  • http://ant.apache.org/manual/tutorial-writing-tasks.html

 



若非注明,均为原创,欢迎转载,转载请注明来源:网站优化之压缩页面内嵌的js代码

关于 JackieAtHome

基层程序员,八年之后重新启航

此条目发表在 Java, Web, 工作总结 分类目录,贴了 , , 标签。将固定链接加入收藏夹。