WebApi+Bootstrap+KnockoutJs打造单页面程序


Posted in Javascript onMay 16, 2016

一、前言

在前一个专题快速介绍了KnockoutJs相关知识点,也写了一些简单例子,希望通过这些例子大家可以快速入门KnockoutJs。为了让大家可以清楚地看到KnockoutJs在实际项目中的应用,本专题将介绍如何使用WebApi+Bootstrap+KnockoutJs+Asp.net MVC来打造一个单页面Web程序。这种模式也是现在大多数公司实际项目中用到的。

二、SPA(单页面)好处
在介绍具体的实现之前,我觉得有必要详细介绍了SPA。SPA,即Single Page Web Application的缩写,是加载单个HTML 页面并在用户与应用程序交互时动态更新该页面的Web应用程序。浏览器一开始会加载必需的HTML、CSS和JavaScript,所有的操作都在这张页面上完成,都由JavaScript来控制。

单页面程序的好处在于:

更好的用户体验,让用户在Web app感受native app的速度和流畅。
分离前后端关注点,前端负责界面显示,后端负责数据存储和计算,各司其职,不会把前后端的逻辑混杂在一起。
减轻服务器压力,服务器只用生成数据就可以,不用管展示逻辑和页面逻辑,增加服务器吞吐量。MVC中Razor语法写的前端是需要服务器完成页面的合成再输出的。
同一套后端程序,可以不用修改直接用于Web界面、手机、平板等多种客户端。

当然单页面程序除了上面列出的优点外,也有其不足:

不利于SEO。这点如果是做管理系统的话是没影响的
初次加载时间相对增加。因为所有的JS、CSS资源会在第一次加载完成,从而使得后面的页面流畅。对于这点可以使用Asp.net MVC中Bundle来进行文件绑定。关于Bundle的详细使用参考文章:https://3water.com/article/84329.htm、https://3water.com/article/84329.htm和https://3water.com/article/82174.htm。
导航不可用。如果一定要导航需自行实现前进、后退。对于这点,可以自行实现前进、后退功能来弥补。其实现在手机端网页就是这么干的,现在还要上面导航的。对于一些企业后台管理系统,也可以这么做。
对开发人员技能水平、开发成本高。对于这点,也不是事,程序员嘛就需要不断学习来充电,好在一些前端框架都非常好上手。
三、使用Asp.net MVC+WebAPI+Bootstrap+KnockoutJS实现SPA

前面详细介绍了SPA的优缺点,接下来,就让我们使用Asp.net MVC+WebAPI+BS+KO来实现一个单页面程序,从而体验下SPA流畅和对原始Asp.net MVC +Razor做出来的页面进行效果对比。

1.使用VS2013创建Asp.net Web应用程序工程,勾选MVC和WebAPI类库。具体见下图:

WebApi+Bootstrap+KnockoutJs打造单页面程序

2. 创建对应的仓储和模型。这里演示的是一个简单任务管理系统。具体的模型和仓储代码如下:

任务实体类实现:

public enum TaskState
 {
 Active = 1,
 Completed =2
 }

 /// <summary>
 /// 任务实体
 /// </summary>
 public class Task
 {
 public int Id { get; set; }

 public string Name { get; set; }
 public string Description { get; set; }

 public DateTime CreationTime { get; set; }

 public DateTime FinishTime { get; set; }

 public string Owner { get; set; }
 public TaskState State { get; set; }

 public Task()
 {
 CreationTime = DateTime.Parse(DateTime.Now.ToLongDateString());
 State = TaskState.Active;
 }
 }

任务仓储类实现:

/// <summary>
 /// 这里仓储直接使用示例数据作为演示,真实项目中需要从数据库中动态加载
 /// </summary>
 public class TaskRepository
 {
 #region Static Filed
 private static Lazy<TaskRepository> _taskRepository = new Lazy<TaskRepository>(() => new TaskRepository());

 public static TaskRepository Current
 {
 get { return _taskRepository.Value; }
 }

 #endregion

 #region Fields
 private readonly List<Task> _tasks = new List<Task>()
 {
 new Task
 {
 Id =1,
 Name = "创建一个SPA程序",
 Description = "SPA(single page web application),SPA的优势就是少量带宽,平滑体验",
 Owner = "Learning hard",
 FinishTime = DateTime.Parse(DateTime.Now.AddDays(1).ToString(CultureInfo.InvariantCulture))
 },
 new Task
 {
 Id =2,
 Name = "学习KnockoutJs",
 Description = "KnockoutJs是一个MVVM类库,支持双向绑定",
 Owner = "Tommy Li",
 FinishTime = DateTime.Parse(DateTime.Now.AddDays(2).ToString(CultureInfo.InvariantCulture))
 },
 new Task
 {
 Id =3,
 Name = "学习AngularJS",
 Description = "AngularJs是MVVM框架,集MVVM和MVC与一体。",
 Owner = "李志",
 FinishTime = DateTime.Parse(DateTime.Now.AddDays(3).ToString(CultureInfo.InvariantCulture))
 },
 new Task
 {
 Id =4,
 Name = "学习ASP.NET MVC网站",
 Description = "Glimpse是一款.NET下的性能测试工具,支持asp.net 、asp.net mvc, EF等等,优势在于,不需要修改原项目任何代码,且能输出代码执行各个环节的执行时间",
 Owner = "Tonny Li",
 FinishTime = DateTime.Parse(DateTime.Now.AddDays(4).ToString(CultureInfo.InvariantCulture))
 },
 };

 #endregion

 #region Public Methods
 public IEnumerable<Task> GetAll()
 {
 return _tasks;
 }

 public Task Get(int id)
 {
 return _tasks.Find(p => p.Id == id);
 }

 public Task Add(Task item)
 {
 if (item == null)
 {
 throw new ArgumentNullException("item");
 }

 item.Id = _tasks.Count + 1;
 _tasks.Add(item);
 return item;
 }

 public void Remove(int id)
 {
 _tasks.RemoveAll(p => p.Id == id);
 }

 public bool Update(Task item)
 {
 if (item == null)
 {
 throw new ArgumentNullException("item");
 }

 var taskItem = Get(item.Id);
 if (taskItem == null)
 {
 return false;
 }

 _tasks.Remove(taskItem);
 _tasks.Add(item);
 return true;
 }
 #endregion 
 }

3. 通过Nuget添加Bootstrap和KnockoutJs库。

4. 实现后端数据服务。这里后端服务使用Asp.net WebAPI实现的。具体的实现代码如下:

/// <summary>
 /// Task WebAPI,提供数据服务
 /// </summary>
 public class TasksController : ApiController
 {
 private readonly TaskRepository _taskRepository = TaskRepository.Current;

 public IEnumerable<Task> GetAll()
 {
 return _taskRepository.GetAll().OrderBy(a => a.Id);
 }

 public Task Get(int id)
 {
 var item = _taskRepository.Get(id);
 if (item == null)
 {
 throw new HttpResponseException(HttpStatusCode.NotFound);
 }

 return item;
 }

 [Route("api/tasks/GetByState")]
 public IEnumerable<Task> GetByState(string state)
 {
 IEnumerable<Task> results = new List<Task>();
 switch (state.ToLower())
 {
 case "":
 case "all":
  results = _taskRepository.GetAll();
  break;
 case "active":
  results = _taskRepository.GetAll().Where(t => t.State == TaskState.Active);
  break;
 case "completed":
  results = _taskRepository.GetAll().Where(t => t.State == TaskState.Completed);
  break;
 }

 results = results.OrderBy(t => t.Id);
 return results;
 }

 [HttpPost]
 public Task Create(Task item)
 {
 return _taskRepository.Add(item);
 }

 [HttpPut]
 public void Put(Task item)
 {
 if (!_taskRepository.Update(item))
 {
 throw new HttpResponseException(HttpStatusCode.NotFound);
 }
 }

 public void Delete(int id)
 {
 _taskRepository.Remove(id);
 }
 }

5. 使用Asp.net MVC Bundle对资源进行打包。对应的BundleConfig实现代码如下:

/// <summary>
 /// 只需要补充一些缺少的CSS和JS文件。因为创建模板的时候已经添加了一些CSS和JS文件
 /// </summary>
 public class BundleConfig
 {
 // For more information on bundling, visit http://go.microsoft.com/fwlink/?LinkId=301862
 public static void RegisterBundles(BundleCollection bundles)
 {
 bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
  "~/Scripts/jquery-{version}.js"));

 bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
  "~/Scripts/jquery.validate*"));

 // Use the development version of Modernizr to develop with and learn from. Then, when you're
 // ready for production, use the build tool at http://modernizr.com to pick only the tests you need.
 bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(
  "~/Scripts/modernizr-*"));

 bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include(
  "~/Scripts/bootstrap.js",
  "~/Scripts/bootstrap-datepicker.min.js"));

 bundles.Add(new StyleBundle("~/Content/css").Include(
  "~/Content/bootstrap.css",
  "~/Content/bootstrap-datepicker3.min.css",
  "~/Content/site.css"));

 bundles.Add(new ScriptBundle("~/bundles/knockout").Include(
  "~/Scripts/knockout-{version}.js",
  "~/Scripts/knockout.validation.min.js",
  "~/Scripts/knockout.mapping-latest.js"));

 bundles.Add(new ScriptBundle("~/bundles/app").Include(
 "~/Scripts/app/app.js"));
 }
 }

6. 因为我们需要在页面上使得枚举类型显示为字符串。默认序列化时会将枚举转换成数值类型。所以要对WebApiConfig类做如下改动:

public static class WebApiConfig
 {
 public static void Register(HttpConfiguration config)
 {
 // Web API 配置和服务

 // Web API 路由
 config.MapHttpAttributeRoutes();

 config.Routes.MapHttpRoute(
 name: "DefaultApi",
 routeTemplate: "api/{controller}/{id}",
 defaults: new { id = RouteParameter.Optional }
 );

 // 使得序列化使用驼峰式大小写风格序列化属性
 config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
 // 将枚举类型在序列化时序列化字符串
 config.Formatters.JsonFormatter.SerializerSettings.Converters.Add(new StringEnumConverter());
 }
 }

注:如果上面没有使用驼峰小写风格序列化的话,在页面绑定数据的时候也要进行调整。如绑定的Name属性的时候直接使用Name大写,如果使用name方式会提示这个属性没有定义错误。由于JS是使用驼峰小写风格对变量命名的。所以建议大家加上使用驼峰小写风格进行序列化,此时绑定的时候只能使用"name"这样的形式进行绑定。这样也更符合JS代码的规范。 

7. 修改对应的Layout文件和Index文件内容。

Layout文件具体代码如下:

<!DOCTYPE html>
<html>
<head>
 <meta charset="utf-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <title> Learninghard SPA Application</title>
 @Styles.Render("~/Content/css")
 @Scripts.Render("~/bundles/modernizr")
</head>
 <body>
 <div class="navbar navbar-inverse navbar-fixed-top">
 <div class="container">
 <div class="navbar-header">
  <p class="navbar-brand">简单任务管理系统</p>
 </div>
 <div class="navbar-collapse collapse">
  <ul class="nav navbar-nav">
  <li class="active"><a href="/">主页</a></li>
  </ul>
 </div>
 </div>
 </div>

 <div class="container body-content" id="main">
 @RenderBody()
 <hr />
 <footer>
 <p>© @DateTime.Now.Year - Learninghard SPA Application</p>
 </footer>
 </div>

 @Scripts.Render("~/bundles/jquery")
 @Scripts.Render("~/bundles/bootstrap")
 @Scripts.Render("~/bundles/knockout")
 @Scripts.Render("~/bundles/app")
 </body>
</html>
Index页面代码如下:
@{
 ViewBag.Title = "Index";
 Layout = "~/Views/Shared/_Layout.cshtml";
}


<div id="list" data-bind="if:canCreate">
<h2>Tasks</h2>
<div class="table-responsive">
 <table class="table table-striped">
 <thead>
 <tr>
 <th>编号</th>
 <th>名称</th>
 <th>描述</th>
 <th>负责人</th>
 <th>创建时间</th>
 <th>完成时间</th>
 <th>状态</th>
 <th></th>
 </tr>
 </thead>
 <tbody data-bind="foreach:tasks">
 <tr>
 <td data-bind="text: id"></td>
 <td><a data-bind="text: name, click: handleCreateOrUpdate"></a></td>
 <td data-bind="text: description"></td>
 <td data-bind="text: owner"></td>
 <td data-bind="text: creationTime"></td>
 <td data-bind="text: finishTime"></td>
 <td data-bind="text: state"></td>
 <td><a class="btn btn-xs btn-primary" data-bind="click:remove" href="javascript:void(0)">Remove</a></td>
 </tr>
 </tbody>
 </table>
</div>
<div class="col-sm-4">
 <a href="javascript:void(0)" data-bind="click: function(data, event){ setTaskList('all') }">All </a> |
 <a href="javascript:void(0)" data-bind="click: function(data, event){ setTaskList('active') }"> Active</a> |
 <a href="javascript:void(0)" data-bind="click: function(data, event){ setTaskList('completed') }"> Completed</a>
</div>
<div class="col-sm-2 col-sm-offset-6">
 <a href="javascript:void(0)" data-bind="click: handleCreateOrUpdate">添加任务</a>
</div>
</div>

<div id="create" style="visibility: hidden">
 <h2>添加任务</h2>
 <br/>
 <div class="form-horizontal">
 <div class="form-group">
 <label for="taskName" class="col-sm-2 control-label">名称 *</label>
 <div class="col-sm-10">
 <input type="text" data-bind="value: name" class="form-control" id="taskName" name="taskName" placeholder="名称">
 </div>
 </div>
 <div class="form-group">
 <label for="taskDesc" class="col-sm-2 control-label">描述</label>
 <div class="col-sm-10">
 <textarea class="form-control" data-bind="value: description" rows="3" id="taskDesc" name="taskDesc" placeholder="描述"></textarea>
 </div>
 </div>
 <div class="form-group">
 <label for="taskOwner" class="col-sm-2 control-label">负责人 *</label>
 <div class="col-sm-10">
 <input class="form-control" id="taskOwner" name="taskOwner" data-bind="value: owner" placeholder="负责人">
 </div>
 </div>
 <div class="form-group">
 <label for="taskFinish" class="col-sm-2 control-label">预计完成时间 *</label>
 <div class="col-sm-10">
 <input class="form-control datepicker" id="taskFinish" data-bind="value: finishTime" name="taskFinish">
 </div>
 </div>
 <div class="form-group">
 <label for="taskOwner" class="col-sm-2 control-label">状态 *</label>
 <div class="col-sm-10">
 <select id="taskState" class="form-control" data-bind="value: state">
  <option>Active</option>
  <option>Completed</option>
 </select>
 
 </div>
 </div>
 <div class="form-group">
 <div class="col-sm-offset-2 col-sm-10">
 <button class="btn btn-primary" data-bind="click:handleSaveClick">Save</button>
 <button data-bind="click: handleBackClick" class="btn btn-primary">Back</button>
 </div>
 </div>
 </div>
</div>

8. 创建对应的前端脚本逻辑。用JS代码来请求数据,并创建对应ViewModel对象来进行前端绑定。具体JS实现代码如下:

var taskListViewModel = {
 tasks: ko.observableArray(),
 canCreate:ko.observable(true)
};

var taskModel = function () {
 this.id = 0;
 this.name = ko.observable();
 this.description = ko.observable();
 this.finishTime = ko.observable();
 this.owner = ko.observable();
 this.state = ko.observable();
 this.fromJS = function(data) {
 this.id = data.id;
 this.name(data.name);
 this.description(data.description);
 this.finishTime(data.finishTime);
 this.owner(data.owner);
 this.state(data.state);
 };
};

function getAllTasks() {
 sendAjaxRequest("GET", function (data) {
 taskListViewModel.tasks.removeAll();
 for (var i = 0; i < data.length; i++) {
 taskListViewModel.tasks.push(data[i]);
 }
 }, 'GetByState', { 'state': 'all' });
}

function setTaskList(state) {
 sendAjaxRequest("GET", function(data) {
 taskListViewModel.tasks.removeAll();
 for (var i = 0; i < data.length; i++) {
 taskListViewModel.tasks.push(data[i]);
 }},'GetByState',{ 'state': state });
}

function remove(item) {
 sendAjaxRequest("DELETE", function () {
 getAllTasks();
 }, item.id);
}

var task = new taskModel();

function handleCreateOrUpdate(item) {
 task.fromJS(item);
 initDatePicker();
 taskListViewModel.canCreate(false);
 $('#create').css('visibility', 'visible');
}

function handleBackClick() {
 taskListViewModel.canCreate(true);
 $('#create').css('visibility', 'hidden');
}

function handleSaveClick(item) {
 if (item.id == undefined) {
 sendAjaxRequest("POST", function (newItem) { //newitem是返回的对象。
 taskListViewModel.tasks.push(newItem);
 }, null, {
 name: item.name,
 description: item.description,
 finishTime: item.finishTime,
 owner: item.owner,
 state: item.state
 });
 } else {
 sendAjaxRequest("PUT", function () {
 getAllTasks();
 }, null, {
 id:item.id,
 name: item.name,
 description: item.description,
 finishTime: item.finishTime,
 owner: item.owner,
 state: item.state
 });
 }
 
 taskListViewModel.canCreate(true);
 $('#create').css('visibility', 'hidden');
}
function sendAjaxRequest(httpMethod, callback, url, reqData) {
 $.ajax("/api/tasks" + (url ? "/" + url : ""), {
 type: httpMethod,
 success: callback,
 data: reqData
 });
}

var initDatePicker = function() {
 $('#create .datepicker').datepicker({
 autoclose: true
 });
};

$('.nav').on('click', 'li', function() {
 $('.nav li.active').removeClass('active');
 $(this).addClass('active');
});

$(document).ready(function () {
 getAllTasks();
 // 使用KnockoutJs进行绑定
 ko.applyBindings(taskListViewModel, $('#list').get(0));
 ko.applyBindings(task, $('#create').get(0));
});

到此,我们的单页面程序就开发完毕了,接下来我们来运行看看其效果。

WebApi+Bootstrap+KnockoutJs打造单页面程序

从上面运行结果演示图可以看出,一旦页面加载完之后,所有的操作都好像在一个页面操作,完全感觉浏览器页面转圈的情况。对比于之前使用Asp.net MVC +Razor开发的页面,你是否感觉了SPA的流畅呢?之前使用Asp.net MVC +Razor开发的页面,你只要请求一个页面,你就可以感受整个页面刷新的情况,这样用户体验非常不好。

四、与Razor开发模式进行对比

相信大家从效果上已经看出SPA优势了,接下来我觉得还是有必要与传统实现Web页面方式进行一个对比。与Razor开发方式主要有以下2点不同:

1.页面被渲染的时候,数据在浏览器端得到处理。而不是在服务器上。将渲染压力分配到各个用户的浏览器端,从而减少网站服务器的压力。换做是Razor语法,前端页面绑定语句应该就是如下:

@Model IEnumerable<KnockoutJSSPA.Models.Task> 
@foreach (var item in Model)
{
 <tr>
 <td>@item.Name</td>
 <td>@item.Description</td>
 </tr>
}

这些都是在服务器端由Razor引擎渲染的。这也是使用Razor开发的页面会看到页面转圈的情况的原因。因为你每切换一个页面的时候,都需要请求服务端进行渲染,服务器渲染完成之后再将html返回给客户端进行显示。

2. 绑定的数据是动态的。意味着数据模型的改变会马上反应到页面上。这效果归功于KnockoutJs实现的双向绑定机制。

采用这种方式,对于程序开发也简单了,Web API只负责提供数据,而前端页面也减少了很多DOM操作。由于DOM操作比较繁琐和容易出错。这样也意味着减少了程序隐性的bug。并且,一个后端服务,可以供手机、Web浏览器和平台多个平台使用,避免重复开发。

五、总结
到此,本文的介绍就介绍了。本篇主要介绍了使用KnockoutJs来完成一个SPA程序。其实在实际工作中,打造单页面程序的模式更多的采用AngularJS。然后使用KnockoutJs也有很多,但是KnockoutJs只是一个MVVM框架,其路由机制需要借助其他一些类库,如我们这里使用Asp.net MVC中的路由机制,你还可以使用director.js前端路由框架。相对于KnockoutJs而言,AngularJs是一个MVVM+MVC框架。所以在下一个专题将介绍使用如何使用AngularJs打造一个单页面程序(SPA)。

本文所有源码下载:SPAWithKnockoutJs

如果大家还想深入学习,可以点击这里进行学习,再为大家附3个精彩的专题:

以上就是本文的全部内容,希望对大家的学习有所帮助。

Javascript 相关文章推荐
使用GruntJS构建Web程序之构建篇
Jun 04 Javascript
javascript 回调函数详解
Nov 11 Javascript
javascript中sort()的用法实例分析
Jan 30 Javascript
ECMAScript6中Map/WeakMap详解
Jun 12 Javascript
jQuery使用$.ajax进行即时验证实例详解
Dec 11 Javascript
纯js和css完成贪吃蛇小游戏demo
Sep 01 Javascript
Javascript 事件冒泡机制详细介绍
Oct 10 Javascript
Angular2安装angular-cli
May 21 Javascript
微信小程序实现pdf、word等格式文件上传的方法
Sep 10 Javascript
vue基本使用--refs获取组件或元素的实例
Nov 07 Javascript
解决Vue中的生命周期beforeDestory不触发的问题
Jul 21 Javascript
基于原生JS封装的Modal对话框插件的示例代码
Sep 09 Javascript
KnockoutJs快速入门教程
May 16 #Javascript
JS学习之表格的排序简单实例
May 16 #Javascript
JavaScript操作选择对象的简单实例
May 16 #Javascript
JS组件Bootstrap实现图片轮播效果
May 16 #Javascript
Bootstrap4一次重大更新 几乎涉及每行代码
May 16 #Javascript
JS获取元素多层嵌套思路详解
May 16 #Javascript
怎么限制input的text里输入的值只能是数字(正则、js)
May 16 #Javascript
You might like
神族 Protoss 剧情介绍
2020/03/14 星际争霸
用PHP编写和读取XML的几种方式
2013/01/12 PHP
php中有关合并某一字段键值相同的数组合并的改进
2015/03/10 PHP
php实现从上传文件创建缩略图的方法
2015/04/02 PHP
PHP 数据结构队列(SplQueue)和优先队列(SplPriorityQueue)简单使用实例
2015/05/12 PHP
Thinkphp 框架扩展之标签库驱动原理与用法分析
2020/04/23 PHP
默认让页面的第一个控件选中的javascript代码
2009/12/26 Javascript
详谈nodejs异步编程
2014/12/04 NodeJs
Jquery中$.post和$.ajax的用法小结
2015/04/28 Javascript
浅谈JavaScript中指针和地址
2015/07/26 Javascript
JavaScript中this详解
2015/09/01 Javascript
JS实现的左侧竖向滑动菜单效果代码
2015/10/19 Javascript
Bootstrap源码解读按钮(5)
2016/12/23 Javascript
Vue.js学习之过滤器详解
2017/01/22 Javascript
nodejs基础知识
2017/02/03 NodeJs
Bootstrap Table使用整理(三)
2017/06/09 Javascript
es7学习教程之Decorators(修饰器)详解
2017/07/21 Javascript
jquery对table做排序操作的实例演示
2017/08/10 jQuery
MVVM框架下实现分页功能示例
2018/06/14 Javascript
Nodejs核心模块之net和http的使用详解
2019/04/02 NodeJs
vue 组件销毁并重置的实现
2020/01/13 Javascript
vue 百度地图(vue-baidu-map)绘制方向箭头折线实例代码详解
2020/04/28 Javascript
在Vue中获取自定义属性方法:data-id的实例
2020/09/09 Javascript
[16:43]Heroes19_剃刀(完美)
2014/10/31 DOTA
python实现石头剪刀布程序
2021/01/20 Python
Python中asyncio模块的深入讲解
2019/06/10 Python
python调用函数、类和文件操作简单实例总结
2019/11/29 Python
新闻学专业应届生求职信
2013/11/08 职场文书
经理职责范文
2013/11/08 职场文书
仓库班组长岗位职责
2013/12/12 职场文书
社区党风廉政建设调研报告
2015/01/01 职场文书
农民工工资保障承诺书
2015/05/04 职场文书
2015年学校禁毒工作总结
2015/05/27 职场文书
六一活动主持词
2015/06/30 职场文书
小组口号霸气押韵
2015/12/24 职场文书
JavaScript实现音乐播放器
2022/08/14 Javascript