JavaScript 模式和性能

多年前,ASP.NET 为我们提供了服务器端 UI 控件呈现,这很好。 不过,服务器端的呈现需要完全信任代码。 既然我们已经转换到了 SharePoint 和 Office 365,完全信任代码将不再是一个选择。 这意味着服务器端 UI 控件的呈现对我们将不再起作用。

但是,企业仍需要自定义 UI 功能,以用于它们的网站和应用。 这意味着自定义 UI 功能必须从服务器端移到客户端。

现在,使用客户端 JavaScript 呈现 UI 控件。

JavaScript 模式

既然客户端 JavaScript 是一种方法,那么实施客户端 JavaScript 的最佳方法是什么? 我们应当如何开始?

有几种方法:

方法 说明
JavaScript 嵌入 Site.UserCustomActions(#site.usercustomactions) 或 Web.UserCustomActions(#web.usercustomactions) 允许将脚本直接添加到页面标记。 这将在 加载程序模式(#加载程序模式) 中使用(以下将加以讨论)。
显示模板 应用到“视图”和“搜索”。 你无需部署任何应用或提供商托管的代码。 它只是一个可以上载到(例如)样式库以自定义视图的 JavaScript 文件。 你可以使用 JavaScript 创建任何所需的视图。
SharePoint 托管的外接程序 使用 JSOM 向主机 Web 或外接程序 Web 传回通信。 它为跨域调用的 Web 代理提供了访问权限
提供商托管的外接程序 启用跨多种技术堆栈的复杂应用程序的创建 - 同时维护 SharePoint 安全集成
JSLink 允许你在多个 OOTB Web 部件和视图中加载一个或多个 JavaScript 文件
ScriptEditor Web 部件 直接添加脚本或通过带有标记的脚本标签加载脚本,以创建完全托管在 SharePoint 网站内部的复杂的单个页面应用程序

请不要将自己局限在这些方法(如果你认为有不同的方法可能更适合你的情况)。

JavaScript 性能

在开发过程中的每一步,都请务必牢记提升性能。 以下是导致 JavaScript 性能产生巨大差异的几个事项:

方法 说明
减少请求数量 请求更少意味着往返服务器的次数也更少,从而减少了延迟。
仅检索你需要的数据 减少线路上所发送的数据量。 此外,减少服务器负载。
提供良好的页面加载体验 保持你的 UI 对用户的响应。 例如,在开始下载超过 100 条记录,更新页面上的菜单。
尽可能使用异步调用和模式 与使用异步调用或回调相比,使用轮询会对性能造成更沉重的负担。
缓存是关键 缓存在进一步减轻服务器负担的同时还能迅速提高服务器性能。
准备比想象还多的页面视图 如果仅有几个点击,具有大量数据的登录页不会出现问题。 不过,如果有数千次点击,可能真的会影响性能。

我的代码在做什么

为了提升性能,请务必了解在任意时间点代码都在做什么。 这可以使你确定提高效率的方法。 下面介绍了这方面的一些最佳做法。

减少请求数量

始终尽可能确保请求最少且最小。 你消除的每个请求都将减少服务器和客户端上的性能负担。 生成更小的请求可以进一步减少性能负担。

有几种减少请求及其大小的方法。

  • 限制生产中的 JavaScript 文件的数量。 分隔 JavaScript 文件有利于开发,但是并不是那么利于生产。 将 JavaScript 文件组合成一个单个 JavaScript 文件,或尽可能组合成较少的 JavaScript 文件。
  • 缩小文件大小。 通过删除换行符、空白空间和注释以尽可能减少生产 JavaScript 文件。 可以使用一些 JavaScript 缩小程序和网站来大大减少 JavaScript 文件的大小。
  • 使用浏览器文件缓存减少请求。 以下更新的加载程序模式可以更好地扩展该方法。

仅检索需要的数据

请求获取数据时,请务必重点请求获取实际所需的数据。 例如:下载整篇文章以获取标题将大大降低性能。

  • 使用服务器筛选、选择和限制以尽量减少线路上的流量。
  • 另一个示例是,若只想阅读文章的前面一部分,就不要请求整篇文章。
  • 若只想要一个属性,就不要请求整个属性包。 例如,只需一个属性的脚本却请求了整个属性包,于是最终产生了 800 KB 的容量。 还请注意,对象大小可能会随时间而改变,因此现在的仅几千字节在以后的产品生命周期中可能会变成几兆字节。

不请求获取将弃用的未用数据

如果你检索了超出你实际使用的数据,将其视为更好地筛选你的初始查询的机会。

  • 仅请求你需要的字段,如名称和地址,而不是整条记录。
  • 使用特定的和经过谨慎考虑的筛选请求。 例如,如果你想列出可用的文章,可使用标题、出版日期和作者字段。 不请求获取其余字段。

提供良好的用户体验

不稳定或不一致的用户界面不仅会影响性能,还会影响实际性能。 以此种方式编写代码以营造流畅的体验。

  • 使用微调框以表示某程序正在加载或正在占用时间。
  • 了解代码的执行顺序,并将其组合为最佳的用户体验。 例如,如果你计划从服务器检索大量数据,并计划通过隐藏菜单来更改用户界面,那么先执行隐藏菜单的代码。 这会防止向用户提供不一的 UI 体验。

所有代码活动均为异步

应将浏览器中的所有代码活动都视为异步。 按某种顺序加载你的文件,必须等待 DOM 加载,返回到 SharePoint 的请求将以不同的速度完成。

  • 了解你的代码如何及时运行。
  • 使用事件和回调代替轮询。
  • 使用承诺。 在 jQuery 中将其称作延迟对象。 Q、WinJS 和 ES6 中也有类似概念。
  • 使用异步调用取代非异步调用。
  • 使用异步随时可能有延迟:
    • 在 AJAX 请求期间。
    • 在任何重要的 DOM 操作期间。

异步模式提高性能和响应,并允许相关操作的有效链接。

客户端缓存

客户端缓存是最常被忽视的提高性能的方法之一,你可以将其添加到你的代码中。

有三个可以缓存你的数据的位置:

方法 说明
会话存储 将数据作为键/值对存储在客户端上。 这是每个会话存储,将始终存储为字符串。

JSON.stringify() 会将 JavaScript 对象转换为有助于存储对象的字符串。
本地存储 将数据作为键/值对存储在客户端上。 这将持久保存在各会话间,将始终存储为字符串。

JSON.stringify() 会将 JavaScript 对象转换为有助于存储对象的字符串。
本地数据库 将关系数据存储在客户端上。 经常将 SQL-Lite 作为数据库引擎使用。

本地数据库存储并非始终在所有浏览器上可用 - 检查目标浏览器支持

进行缓存时,请记住你可用的存储限制和你的数据的刷新。

  • 如果你已达到存储上限,删除旧的或不太重要的缓存数据也许是明智之举。
  • 不同种类的数据其过期的时间也不同。 一个新闻文章的列表可能在五到十分钟后过期,而一个用户的配置文件名称通常可安全缓存达 24 小时或更长的时间。

本地和会话存储没有内置过期,但是 cookie 具有内置过期。 你可以将存储的数据连接到 cookie,以向你的本地和会话存储添加过期。 此外,还可以创建包含到期日期的存储包装器,并检查代码是否符合这一要求。

热门的代价

页面多久有人查看一次? 在典型方案中,企业主页设置为跨组织的所有浏览器的启动页面。 然后你会突然收到远超你想象的大量的流量。 内容的每个字节都在服务器性能以及你的主页消耗的带宽中被突然放大。

解决方案:转到主页并链接到其他内容。

具有大量数据的仪表板也是提供程序托管的应用的候选,可对其独立扩展。

加载程序模式

加载程序模式旨在提供一种方法,将数量未知的远程脚本嵌入网站,而无需更新网站。 可在远程 CDN 上完成更新,并将更新所有网站。

加载程序模式将在末尾构建一个具有日期和时间戳的 URL,以避免文件被缓存。 它将 jQuery 设置为加载程序文件上的依赖关系,然后在加载程序中执行函数。 这将确保 jQuery 完成加载后会加载你的自定义 JavaScript。

PnP-dev\Samples\Core.JavaScript\Core.JavaScript.Embedder\Program.cs:

static void Main(string[] args)
{
    ContextManager.WithContext((context) =>
        // this is the script block that will be embedded into the page
        // in practice this can be done during provisioning of the site/web
        // make sure to include ';' at end to play nice with page embedding
        // using the script on demand feature built into SharePoint we load jQuery, then our remote loader(pnp-loader.js or pnp-loader-cached.js) file using a dependency
        var script = @"(function (loaderFile, nocache) {
                                var url = loaderFile + ((nocache) ? '?' + encodeURIComponent((new Date()).getTime()) : '');
                                SP.SOD.registerSod('pnp-jquery.js', 'https://localhost:44324/js/jquery.js');
                                SP.SOD.registerSod('pnp-loader.js', url);
                                SP.SOD.registerSodDep('pnp-loader.js', 'pnp-jquery.js');
                                SP.SOD.executeFunc('pnp-loader.js', null, function() {});
                        })('https://localhost:44324/pnp-loader.js', true);";


        // this version of the script along with pnp-loaderMDS.js (or pnp-loaderMDS-cached.js) handles pages where the minimum download strategy is active
        var script2 = @"ExecuteOrDelayUntilBodyLoaded(function () {
                            var url = 'https://localhost:44324/js/pnp-loaderMDS.js?' + encodeURIComponent((new Date()).getTime());
                            SP.SOD.registerSod('pnp-jquery.js', 'https://localhost:44324/js/jquery.js');
                            SP.SOD.registerSod('pnp-loader.js', url);
                            SP.SOD.registerSodDep('pnp-loader.js', 'pnp-jquery.js');
                            SP.SOD.executeFunc('pnp-loader.js', null, function () {
                                if (typeof pnpLoadFiles === 'undefined') {
                                    RegisterModuleInit('https://localhost:44324/js/pnp-loaderMDS.js', pnpLoadFiles);
                                } else {
                                    pnpLoadFiles();
                                }
                            });
                        });";

        // load the collection of existing links
        var links = context.Site.RootWeb.UserCustomActions;
        context.Load(links, ls => ls.Include(l => l.Title));
        context.ExecuteQueryRetry();

        // this block handles deleting previous test custom actions
        var doDelete = false;

        foreach (var link in links.ToArray().Where(l => l.Title.Equals("MyTestCustomAction", StringComparison.OrdinalIgnoreCase)))
        {
            link.DeleteObject();
            doDelete = true;
        }

        if (doDelete)
        {
            context.ExecuteQueryRetry();
        }

        // now we embed our script into the user custom action
        var newLink = context.Site.RootWeb.UserCustomActions.Add();
        newLink.Title = "MyTestCustomAction";
        newLink.Description = "Doing some testing.";
        newLink.ScriptBlock = script2;
        newLink.Location = "ScriptLink";
        newLink.Update();
        context.ExecuteQueryRetry();
    });
}

SP.SOD.registerSodDep('pnp-loader.js', 'pnp-jquery.js'); 设置依赖关系,并且 SP.SOD.executeFunc('pnp-loader.js', null, function() {}); 强制在加载自定义 JavaScript 前完全加载 jQuery。

newLink.ScriptBlock = script2;newLink.Location = "ScriptLink"; 是将其添加到用户自定义操作的关键部件。

然后,pnp-loader.js 文件将加载一个 JavaScript 文件列表,并承诺其可在每个文件加载时运行。

PnP-dev\Samples\Core.JavaScript\Core.JavaScript.CDN\js\pnp-loader.js:

(function () {

    var urlbase = 'https://localhost:44324';
    var files = [
        '/js/pnp-settings.js',
        '/js/pnp-core.js',
        '/js/pnp-clientcache.js',
        '/js/pnp-config.js',
        '/js/pnp-logging.js',
        '/js/pnp-devdashboard.js',
        '/js/pnp-uimods.js'
    ];

    // create a promise
    var promise = $.Deferred();

    // this function will be used to recursively load all the files
    var engine = function () {

        // maintain context
        var self = this;

        // get the next file to load
        var file = self.files.shift();

        var fullPath = urlbase + file;

        // load the remote script file
        $.getScript(fullPath).done(function () {
            if (self.files.length > 0) {
                engine.call(self);
            }
            else {
                self.promise.resolve();
            }
        }).fail(self.promise.reject);
    };

    // create our "this" we will apply to the engine function
    var ctx = {
        files: files,
        promise: promise
    };

    // call the engine with our context
    engine.call(ctx);

    // give back the promise
    return promise.promise();

})().done(function () {
    /* all scripts are loaded and I could take actions here */
}).fail(function () {
    /* something failed, take some action here if needed */
});

Pnp-loader.js 文件不会进行缓存,非常适合开发环境。 Pnp-loader-cached.js 文件使用 $.ajax 函数替换 $.getScript 函数,可允许浏览器缓存文件,并且更适合于生产。

来自 PnP-dev\Samples\Core.JavaScript\Core.JavaScript.CDN\js\pnp-loader.js

    // load the remote script file
    $.ajax({
        type: 'GET',
        url: fullPath,
        cache: true,
        dataType: 'script'
    }).done(function () {
        if (self.files.length > 0) {
            engine.call(self);
        }
        else {
            self.promise.resolve();
        }
    }).fail(self.promise.reject);

此模式简化了对网站的部署和更新。 在部署或更新数以千计的网站集时,此模式尤其有用。

缓存当前用户

如果已缓存用户信息,此函数从会话缓存中获取数据。 如果未缓存用户信息,它将获取我们需要的特定用户信息,并将其存储在缓存中。

它也使用延迟(承诺的 jQuery 版本)。 如果我们从缓存或服务器中获取数据,则解决了延迟。 如果出现错误,则拒绝延迟。

取自 PnP-dev\Samples\Core.JavaScript\Core.JavaScript.CDN\js\pnp-core.js:

    getCurrentUserInfo: function (ctx) {

        var self = this;

        if (self._currentUserInfoPromise == null) {

            self._currentUserInfoPromise = $.Deferred(function (def) {

                var cachingTest = $pnp.session !== 'undefined' && $pnp.session.enabled;

                // if we have the caching module loaded
                if (cachingTest) {
                    var userInfo = $pnp.session.get(self._currentUserInfoCacheKey);
                    if (userInfo !== null) {
                        self._currentUserInfo = userInfo;
                        def.resolveWith(ctx || self._currentUserInfo, [self._currentUserInfo]);
                        return;
                    }
                }

                // send the request and allow caching
                $.ajax({
                    method: 'GET',
                    url: '/_api/SP.UserProfiles.PeopleManager/GetMyProperties?$select=AccountName,DisplayName,Title',
                    headers: { "Accept": "application/json; odata=verbose" },
                    cache: true
                }).done(function (response) {

                    // we also parse and add some custom properties as an example
                    self._currentUserInfo = $.extend(response.d,
                        {
                            ParsedLoginName: $pnp.core.getUserIdFromLogin(response.d.AccountName)
                        });

                    if (cachingTest) {
                        $pnp.session.add(self._currentUserInfoCacheKey, self._currentUserInfo);
                    }

                    def.resolveWith(ctx || self._currentUserInfo, [self._currentUserInfo]);

                }).fail(function (jqXHR, textStatus, errorThrown) {

                    console.error('[PNP]=>[Fatal Error] Could not load current user data data from /_api/SP.UserProfiles.PeopleManager/GetMyProperties. status: ' + textStatus + ', error: ' + errorThrown);
                    def.rejectWith(ctx || null);
                });
            });
        }

        return this._currentUserInfoPromise.promise();
    }
}

异步调用 Deferred 的缓存模式

pnp-clientcache.js storageTest 中还有一种缓存模式,取自新式 storageTest。 它包含 add、get、remove 和 getOrAdd 函数。如果缓存中有数据,那么 getOrAdd 函数会返回缓存的数据;如果缓存中没有,那么此函数会从服务器检索数据,并将数据添加到缓存中,这样就无需在调用函数中重复编写代码了。 get 使用 JSON.parse 针对到期功能进行测试,因为本地存储没有到期功能。 _createPersistable 将 JavaScript 对象存储在本地存储缓存中。

取自 PnP-dev\Samples\Core.JavaScript\Core.JavaScript.CDN\js\pnp-clientcache.js:

// adds the client cache capability
caching: {

    // determine if we have local storage once
    enabled: storageTest(),

    add: function (/*string*/ key, /*object*/ value, /*datetime*/ expiration) {

        if (this.enabled) {
            localStorage.setItem(key, this._createPersistable(value, expiration));
        }
    },

    // gets an item from the cache, checking the expiration and removing the object if it is expired
    get: function (/*string*/ key) {

        if (!this.enabled) {
            return null;
        }

        var o = localStorage.getItem(key);

        if (o == null) {
            return o;
        }

        var persistable = JSON.parse(o);

        if (new Date(persistable.expiration) <= new Date()) {

            this.remove(key);
            o = null;

        } else {

            o = persistable.value;
        }

        return o;
    },

    // removes an item from local storage by key
    remove: function (/*string*/ key) {

        if (this.enabled) {
            localStorage.removeItem(key);
        }
    },

    // gets an item from the cache or adds it using the supplied getter function
    getOrAdd: function (/*string*/ key, /*function*/ getter) {

        if (!this.enabled) {
            return getter();
        }

        if (!$.isFunction(getter)) {
            throw 'Function expected for parameter "getter".';
        }

        var o = this.get(key);

        if (o == null) {
            o = getter();
            this.add(key, o);
        }

        return o;
    },

    // creates the persisted object wrapper using the value and the expiration, setting the default expiration if none is applied
    _createPersistable: function (/*object*/ o, /*datetime*/ expiration) {

        if (typeof expiration === 'undefined') {
            expiration = $pnp.core.dateAdd(new Date(), 'minute', $pnp.settings.localStorageDefaultTimeoutMinutes);
        }

        return JSON.stringify({
            value: o,
            expiration: expiration
        });
    }
},

有关异步和延迟的更复杂的使用,请参考 pnp-clientcache.js 中的开发人员仪表板

资源

另请参阅

示例