当前位置: 首页 > 科技观察

除了Vue、React、JQuery等第三方js,我们应该怎么写代码呢?

时间:2023-03-20 00:05:10 科技观察

第三方js的现状无论你是新手还是资深开发者,前端圈子里的人一定都听过这个第三方js的名字。一方面是因为太受欢迎了:各种文章对比框架,分析源码等等。GitHub上的star数量正在快速增长。各种针对框架的培训课程层出不穷。...另一方面,因为使用它们开发非常方便:您可以使用脚手架工具通过几行命令快速构建项目。减少大量重复代码,结构更清晰,可读性强。有丰富的UI库和插件库。……但是一则GitHub放弃使用JQuery的消息让我思考:第三方js除了方便还有什么副作用?没有第三方js能写出高效的代码吗?第三方js的副作用雪上加霜。如果现在让你开发一个项目,你会怎么做?假设你熟悉React,你可以使用create-react-app来快速构建一个项目。太棒了,react、react-dom、react-router-dom都写好了package.json,但是事情还没有结束。如何处理http请求?引入axios。日期呢?介绍时刻或日子。……要知道,这种“拿来主义”是会“上瘾”的,所以第三方依赖就像滚雪球一样。随着开发的不断增加,***占用的体积越来越大。如果使用webpack-bundle-analyzer工具来分析项目,会发现大部分项目代码都在node_modules目录下,也就是说都是第三方js。典型的80%规则(80%的源代码只占编译量的20%)。类似下图:所以我们要开始优化了,比如治标不治本的codesplit(代码量没有减少,只是split),比如treeshaking(你确定代码抖动后只有Thecodeyoureallydependen?),且优化效果有限,更糟糕的是bundleofdependencies。比如ant-design模块的date组件就依赖于moment,我们在使用的时候就引入了moment。而且即使发现更小的dayjs基本可以替代moment的功能,我也不敢引入,因为用date组件替代会出问题,同时引入会增加项目体积。一些第三方js统称为“全家桶”。这个名字让我想起了PC端的一些工具和软件。本来你只是想装个电脑管家,结果它老是弹窗提醒你电脑不安全。建议你安装一个杀毒软件,它会提醒你这个软件很久没更新了,提醒你安装某个软件管家……一开始你只想安装一个,但是最后,你安装了整个家庭。工具归化如果你注意观察,在这些第三方js的用户中,你会看到一些现象:Exclusive。一些使用MV*框架的开发者喜欢站队讨论。例如,喜欢使用VueJS的开发人员可能会抱怨ReactJS,而喜欢Angular的开发人员可能会抱怨VueJS。浮躁。一些没有经验的开发者会认为用JavaScript来操作DOM效率低下,所以干脆使用第三方的js双向数据绑定。自己写XMLHTTPRequest发送请求,第三方js直接调用,多麻烦啊。有限的。有的面试官认为自己熟悉了某个第三方js后,技能就不错了(甚至很多时候,这个“熟”是要加引号的),也就是说掌握了某个第三方js就等于掌握了前端.这些第三方js本来是提高开发效率的工具,却在不知不觉中驯化了开发者,让他们产生了依赖。如果每次都让你开发一个新的项目,你就得依赖第三方js提供的脚手架搭建项目,然后才能开始写代码。那么很可能你已经形成了一种工具思维,就像手里拿着锤子,什么都是钉子,你处理问题和回答的方式,看问题的角度很可能会受此限制。同时,也意味着你离底层原生编码越来越远。越不熟悉原生API,越是只能依赖第三方js等等。如何打破这种局面?先推荐张新旭的一篇文章《不破不立的哲学与个人成长》,当然是抛弃他们了。这里要注意,我说的放弃并不是说所有的项目都自己写框架,从效率上来说是不可能的。比较推荐在一些时间比较充裕,影响(规模)较小的项目中尝试。比如开发某公司内部使用的小工具,或者时间不紧的页面数量少的小项目(看个人开发速度)。在使用原生API进行开发时,我们可以参考以下两条建议。理解本质虽然我们没有使用任何第三方的js,但是我们可以学习一下它的原理和实现。例如,如果你知道数据绑定的实现方式有脏值检测和Object.defineProperty,那么你在写代码的时候就可以使用它们,你会发现离理解这些原理还有很长的路要走并实际使用它们。从另一个角度来说,这也可以进一步加深我们对第三方js的理解。当然,我们的目的不是重新打造一个山寨版的js,而是对现有的技术和思想进行适当的组合、删减和优化,定制最适合业务的代码。文章中提到的第三方js流行的重要原因之一是因为对DOM操作进行了优化甚至隐藏。jQuery自称是DOM操作的利器,将DOM封装成JQ对象并扩展了API,而MV框架之所以取代JQuery,是因为它在DOM操作的道路上做得更好,直接屏蔽了底层操作并将数据映射到高级模板。如果这些MV的思维方式还停留在DOM层面,估计发展不到今天的规模。因为屏蔽DOM只是简化了代码,要构建大型项目,还必须考虑代码组织的问题,即抽象和复用。这些第三方js选择的方式是“组件化”,将HTML、js、CSS封装在一个具有独立作用域的组件中,形成一个可复用的代码单元。下面我们在不引入任何第三方js的情况下实现。无依赖实践web组件我们先来看组件化。事实上,浏览器本身就支持组件化(webcomponents),它由三个关键技术组成。让我们先快速浏览一下。自定义元素(customelements)一组允许自定义元素及其行为的jsAPI,然后可以在您的用户界面中按需要使用。简单示例://定义组件类classLoginFormextendsHTMLElement{constructor(){super();...}}//注册组件customElements.define('login-form',LoginForm);ShadowDOM(影子DOM)一组jsAPI,用于创建可见的DOM树,它会依附于某个DOM元素。这棵树的根节点称为shadowroot,内部的shadowdom只能通过shadowroot访问,外部的css样式不会影响shadowdom。相当于创建了一个独立的作用域。普通的shadowroot可以通过浏览器的调试工具查看:简单例子://'open'表示可以通过js函数访问shadowdomconstshadow=dom.attachShadow({mode:'open'})//操作shadowdomshadow。附加子(h1);HTML模板(HTMLtemplate)HTML模板技术包含两个标签:和。当你需要在页面上复用相同的DOM结构时,可以用模板标签将它们包裹起来,然后再复用。slot标签让模板更加灵活,允许用户自定义模板中的一些内容。一个简单的例子如下:

Myparagraph

//templateusagelettemplate=document.getElementById('my-paragraph');lettemplateContent=template.content;document.body.appendChild(templateContent);让'shavesomedifferenttext!

Let'shavesomedifferenttext!

MDN也提供了一些简单的例子。这是一个完整的例子:conststr=`

Mydefaulttext

`classMyParagraphextendsHTMLElement{constructor(){super();consttemplate=document.createElement('template');template.innerHTML=str;consttemplateContent=template.content;this.attachShadow({mode:'open'}).appendChild(templateContent.cloneNode(true));}}customElements.define('my-paragraph',MyParagraph);完成组件但是这样的组件功能太弱了,因为很多时候需要组件交互,比如父组件给子组件传递参数,子组件调用父组件回调函数。既然是HTML标签,自然会想到通过属性来传递。正好组件还有一个生命周期函数,可以监听属性的变化,看起来很完美!但是问题又来了,首先是性能问题,会增加对dom的读写操作。二是数据类型问题。HTML标签只能传递字符串等简单数据,而对对象、数组、函数等复杂数据无能为力。你大概会想到序列化和反序列化来实现,一个是让页面变得很丑(想象一下一个长度为100的数组参数序列化后的样子)。二是操作复杂。连续的序列化和反序列化容易出错,增加性能消耗。三是有些数据是不能序列化的,比如正则表达式、日期对象等,好在我们可以通过选择器获取DOM实例来传递参数。但是这种情况下,不可避免地要对DOM进行操作,这并不是一个很好的处理方式。另一方面,对于组件而言,如果我们需要在页面上动态显示一些数据,我们也需要对DOM进行操作。组件内部视图和数据通信以数据绑定的形式将数据映射到视图,视图的改变以事件绑定的形式影响数据。如何绑定数据杨会在视图和数据之间建立绑定关系,通常的方法是通过特定的模板语法来实现,比如使用指令。例如,使用x-bind命令将数据体插入到视图的文本内容中。我们不考虑脏值检测机制的性能损失,所以剩下的就是使用Object.defineProperty来实现,它监听属性值的变化。同时需要注意的是,一个数据可以对应多个视图,所以不能直接监听,必须建立一个队列进行处理。梳理一下实现思路:使用选择器找到带有x-bind属性的元素和属性的值,比如
的属性值为text。创建监听队列调度器,保存属性值和对应元素的处理函数。比如上面的元素监听text属性,处理函数为this.textContent=value;创建一个数据模型状态,编写相应属性的set函数,当值发生变化时在dispatcher中执行该函数。示例代码://指令选择器和对应的处理函数constmap={'x-bind'(value){this.textContent=undefined===value?'':value;}};//创建监听队列并监听data对象属性值得改变,然后遍历执行函数for(constpinmap){forEach(this.qsa(`[${p}]`),dom=>{constproperty=attr(dom,p).split('.').shift();this.dispatcher[property]=this.dispatcher[property]||[];constfn=map[p].bind(dom);fn(this.state[property]);this.dispatcher[property].push(fn);});}for(constpropertyinthis.dispatcher){defineProperty(property);}//监控数据对象属性constdefineProperty=p=>{constprefix='_s_';Object.defineProperty(this.state,p,{get:()=>{returnthis[prefix+p];},set:value=>{if(this[prefix+p]!==value){this.dispatcher[p].forEach(fun=>fun(value,this[prefix+p]));this[prefix+p]=value;}}});};这里不是在操作DOM吗?没关系,我们可以将DOM操作放到基类中,那么业务组件就不用去接触DOM了。总结:这里同样使用了VueJS的数据绑定方式,但是由于数据对象属性只能有一个set函数,所以建立一个监听队列来处理不同元素的数据绑定。这种队列遍历方式与AngularJS的脏值检测机制有些类似,只是触发机制不同,数组长度更小。事件绑定事件绑定的思路比数据绑定简单,直接在DOM元素上监听即可。我们以点击事件为例进行绑定,创建事件绑定命令,如x-click。实现思路:使用DOM选择器查找具有x-click属性的元素。读取x-click的属性值,此时我们需要对属性值进行判断,因为属性值可能是x-click=fn这样的函数名,也可能是函数调用x-click=fn(一,真实)。判断基本数据类型,比如boolean值和string,添加到调用参数列表中。为DOM元素添加事件监听器,当事件触发时调用相应的函数,并传入参数。示例代码:constmap=['x-click'];map.forEach(event=>{forEach(this.qsa(`[${event}]`),dom=>{//获取属性值constproperty=attr(dom,event);//获取函数名constfnName=property.split('(')[0];//获取函数参数constparams=property.indexOf('(')>0?property.replace(/.*\((.*)\)/,'$1').split(','):[];letargs=[];//解析函数参数params.forEach(param=>{constp=param.trim();conststr=p.replace(/^'(.*)'$/,'$1').replace(/^"(.*)"$/,'$1');if(str!==p){//stringargs.push(str);}elseif(p==='真'||p==='假'){//booleanargs.push(p==='真');}elseif(!isNaN(p)){args.push(p*1);}else{args.push(this.state[p]);}});//监听事件on(event.replace('x-',''),dom,e=>{//调用函数并传入参数this[fnName](...params,e);});});});表单控件的双向数据绑定也很容易,即建立数据绑定后修改值,然后建立事件绑定监听输入事件,组件之间进行通信就可以了,解决了映射inte的问题后组件内部的视图和数据,让我们开始解决组件之间的通信问题。组件需要提供一个属性对象来接收参数,我们将其设置为props。Parent=>child,datatransfer父组件要给子组件的props属性传值,需要获取子组件的实例,然后修改props属性。在这种情况下,操作DOM是不可避免的,所以我们考虑将DOM操作方法放在基类中。那么问题来了,如何找出哪些标签是子组件,哪些子组件的属性需要绑定呢?能否通过命名约定和选择获得?比如组件名称以cmp-开头,选择器暂时不支持不用说,这个要求不仅约束了编码命名,而且没有规范保证。简单地说,没有静态检测机制。如果开发者写的组件不是以cmp-开头,在运行时检查数据传输是否失败会很麻烦。所以可以在另一个地方收集组件名称,即注册组件功能。我们通过customElements.define函数注册组件。一种方式是在注册组件时直接重载函数并记录组件名称。但是实现起来比较困难,很难保证修改nativeAPI函数不会引起其他代码。影响。所以折中的办法就是对齐封装,然后使用封装好的函数来注册组件。这样我们就可以记录所有注册的组件名称,然后创建一个实例来获取对应的props。我们已经解决了上面提出的问题。同时在要监控的props对象的属性上写set函数。我们只进行了一半,因为我们还没有将数据传递给孩子。如果我们不想对DOM进行操作,只能使用现有的数据绑定机制,将需要传递的属性绑定到数据对象上。整理一下思路:在写子组件的时候创建一个props对象,把需要传递的属性声明为参数,比如this.props={id:''}。编写子组件时,不要使用原生的customElements.define,而是使用封装好的函数,如defineComponent进行注册,这样就可以记录组件名称和对应的props属性。父组件在使用子组件时,会遍历找到子组件和对应的props对象。将子组件的props对象的属性绑定到父组件的data对象的state属性上,这样当父组件的state属性的值发生变化时,子组件的props属性的值就会被自动修改。示例代码:constcomponents={};/***注册组件函数*@param{string}组件(标签)名称*@param{class}组件实现类*/exportconstdefineComponent=(name,componentClass)=>{//registerComponentcustomElements.define(name,componentClass);//创建组件实例constcmp=document.createElement(name);//存储组件名称和对应的props属性components[name]=Object.getOwnPropertyNames(cmp.props)||[];};//注册子组件classChildComponenttextendsComponent{constructor(){//通过基类创建模板//监听propssuper(template,{id:value=>{//...}});}}defineComponent('child-component',ChildComponent);//注册父组件classParentComponenttextendsComponent{constructor(){super(template);this.state.myId='xxx';}}上面的代码还有很多地方可以进一步优化,具体可以参考文末的示例代码。Child=>Parent,callbackfunction子组件的参数要传回给父组件,可以是回调函数的形式。比较麻烦的时候,调用函数的时候需要用到父组件的作用域。可以将父组件的函数绑定到scope上,然后传入子组件的props对象属性,这样子组件就可以正常调用和传递参数了。因为回调函数和参数的运行方式不同,参数是被动接收的,回调函数是主动调用的,所以需要在声明的时候进行标记,比如引用scope的声明方式AngularJS命令的object属性,使用“&”符号表示回调函数。理清思路:在子组件类中声明props的属性为回调函数,如this.props={onClick:'&'}。父组件初始化时,在模板上传递相应的属性,如。根据子组件的属性值找到对应的父组件函数,然后将父组件函数绑定到作用域中传入。如childComponent.props.onClick=this.click.bind(this)。在子组件中调用父组件函数,比如this.props.onClick(...)。示例代码://注册子组件classChildComponenttextendsComponent{constructor(){//通过基类super(template,{onClick:'&'})声明回调函数属性;...this.props.onClick(...);}}defineComponent('child-component',ChildComponent);//注册父组件classParentComponenttextendsComponent{constructor(){super(template);}//事件传递放在基类中操作click(data){...}}跨组件层级通信有些组件需要子孙组件进行通信,逐层层传输将需要编写大量额外的代码,因此我们可以通过总线模式来完成。即建立一个全局模块,数据发送方发送消息和数据,数据接收方监听。示例代码//bus.js//监听队列constdispatcher={};/***接收消息*name*/exportconston=(name,cb)=>{dispatcher[name]=dispatcher[name]||[];constkey=Math.random().toString(26).substring(2,10);//将监听函数放入队列,生成唯一的keydispatcher[name].push({key,fn:cb});returnkey;};//发送消息exportconstemit=function(name,data){constdispatchers=dispatcher[name]||[];//轮询监听队列并调用函数dispatchers.forEach(dp=>{dp.fn(data,this);});};//取消监听exportconstun=(name,key)=>{constlist=dispatcher[name]||[];constindex=list.findIndex(item=>item.key===key);//从监听队列中移除监听函数if(index>-1){list.splice(index,1);returntrue;}else{returnfalse;}};//ancestor.jsimport{on}from'./bus.js';classAncestorComponenttextendsComponent{constructor(){super();on('finish',data=>{//...})}}//child.jsclassChildComponenttextendsComponent{constructor(){super();emit('finish',data);}}总结基类的详细代码可以参考文末仓库地址。目前的项目遵循按需添加的原则,只实现了一些基本的操作,并没有写出所有可能的指令。所以称它为“框架”还不够,只是为你提供实现思路和编写原生代码的信心。