javascript组件化

落日映苍穹つ 2022-08-04 14:56 263阅读 0赞

作为一名前端工程师,写组件的能力至关重要。虽然javascript经常被人嘲笑是个小玩具,但是在一代代大牛的前仆后继的努力下,渐渐的也摸索了一套组件的编写方式。

下面我们来谈谈,在现有的知识体系下,如何很好的写组件。

比如我们要实现这样一个组件,就是一个输入框里面字数的计数。这个应该是个很简单的需求。

图片

我们来看看,下面的各种写法。

为了更清楚的演示,下面全部使用jQuery作为基础语言库。

最简陋的写法

嗯 所谓的入门级写法呢,就是完完全全的全局函数全局变量的写法。(就我所知,现在好多外包还是这种写法)

代码如下:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <title>test</title>
  6. <script src="http://code.jquery.com/jquery-1.9.1.min.js"></script>
  7. <script>
  8. $(function() {
  9. var input = $('#J_input');
  10. //用来获取字数
  11. function getNum(){
  12. return input.val().length;
  13. }
  14. //渲染元素
  15. function render(){
  16. var num = getNum();
  17. //没有字数的容器就新建一个
  18. if ($('#J_input_count').length == 0) {
  19. input.after('<span id="J_input_count"></span>');
  20. };
  21. $('#J_input_count').html(num+'个字');
  22. }
  23. //监听事件
  24. input.on('keyup',function(){
  25. render();
  26. });
  27. //初始化,第一次渲染
  28. render();
  29. })
  30. </script>
  31. </head>
  32. <body>
  33. <input type="text" id="J_input"/>
  34. </body>
  35. </html>

作用域隔离这段代码跑也是可以跑的,但是呢,各种变量混乱,没有很好的隔离作用域,当页面变的复杂的时候,会很难去维护。目前这种代码基本是用不了的。当然少数的活动页面可以简单用用。

让我们对上面的代码作些改动,使用单个变量模拟命名空间。

  1. var textCount = {
  2. input:null,
  3. init:function(config){
  4. this.input = $(config.id);
  5. this.bind();
  6. //这边范围对应的对象,可以实现链式调用
  7. return this;
  8. },
  9. bind:function(){
  10. var self = this;
  11. this.input.on('keyup',function(){
  12. self.render();
  13. });
  14. },
  15. getNum:function(){
  16. return this.input.val().length;
  17. },
  18. //渲染元素
  19. render:function(){
  20. var num = this.getNum();
  21. if ($('#J_input_count').length == 0) {
  22. this.input.after('<span id="J_input_count"></span>');
  23. };
  24. $('#J_input_count').html(num+'个字');
  25. }
  26. }
  27. $(function() {
  28. //在domready后调用
  29. textCount.init({id:'#J_input'}).render();
  30. })

但是还是有些瑕疵,这种写法没有私有的概念,比如上面的getNum,bind应该都是私有的方法。但是其他代码可以很随意的改动这些。当代码量特别特别多的时候,很容易出现变量重复,或被修改的问题。这样一改造,立马变的清晰了很多,所有的功能都在一个变量下面。代码更清晰,并且有统一的入口调用方法。

于是又出现了一种函数闭包的写法:

  1. var TextCount = (function(){
  2. //私有方法,外面将访问不到
  3. var _bind = function(that){
  4. that.input.on('keyup',function(){
  5. that.render();
  6. });
  7. }
  8. var _getNum = function(that){
  9. return that.input.val().length;
  10. }
  11. var TextCountFun = function(config){
  12. }
  13. TextCountFun.prototype.init = function(config) {
  14. this.input = $(config.id);
  15. _bind(this);
  16. return this;
  17. };
  18. TextCountFun.prototype.render = function() {
  19. var num = _getNum(this);
  20. if ($('#J_input_count').length == 0) {
  21. this.input.after('<span id="J_input_count"></span>');
  22. };
  23. $('#J_input_count').html(num+'个字');
  24. };
  25. //返回构造函数
  26. return TextCountFun;
  27. })();
  28. $(function() {
  29. new TextCount().init({id:'#J_input'}).render();
  30. })

面向对象这种写法,把所有的东西都包在了一个自动执行的闭包里面,所以不会受到外面的影响,并且只对外公开了TextCountFun构造函数,生成的对象只能访问到init,render方法。这种写法已经满足绝大多数的需求了。事实上大部分的jQuery插件都是这种写法。

上面的写法已经可以满足绝大多数需求了。

但是呢,当一个页面特别复杂,当我们需要的组件越来越多,当我们需要做一套组件。仅仅用这个就不行了。首先的问题就是,这种写法太灵活了,写单个组件还可以。如果我们需要做一套风格相近的组件,而且是多个人同时在写。那真的是噩梦。

在编程的圈子里,面向对象一直是被认为最佳的编写代码方式。比如java,就是因为把面向对象发挥到了极致,所以多个人写出来的代码都很接近,维护也很方便。但是很不幸的是,javascript不支持class类的定义。但是我们可以模拟。

下面我们先实现个简单的javascript类:

  1. var Class = (function() {
  2. var _mix = function(r, s) {
  3. for (var p in s) {
  4. if (s.hasOwnProperty(p)) {
  5. r[p] = s[p]
  6. }
  7. }
  8. }
  9. var _extend = function() {
  10. //开关 用来使生成原型时,不调用真正的构成流程init
  11. this.initPrototype = true
  12. var prototype = new this()
  13. this.initPrototype = false
  14. var items = Array.prototype.slice.call(arguments) || []
  15. var item
  16. //支持混入多个属性,并且支持{}也支持 Function
  17. while (item = items.shift()) {
  18. _mix(prototype, item.prototype || item)
  19. }
  20. // 这边是返回的类,其实就是我们返回的子类
  21. function SubClass() {
  22. if (!SubClass.initPrototype && this.init)
  23. this.init.apply(this, arguments)//调用init真正的构造函数
  24. }
  25. // 赋值原型链,完成继承
  26. SubClass.prototype = prototype
  27. // 改变constructor引用
  28. SubClass.prototype.constructor = SubClass
  29. // 为子类也添加extend方法
  30. SubClass.extend = _extend
  31. return SubClass
  32. }
  33. //超级父类
  34. var Class = function() {}
  35. //为超级父类添加extend方法
  36. Class.extend = _extend
  37. return Class
  38. })()

这边只是很简陋的实现了类的继承机制。如果对类的实现有兴趣可以参考我另一篇文章 javascript oo实现这是拿John Resig的class简单修改了下。

我们看下使用方法:

  1. //继承超级父类,生成个子类Animal,并且混入一些方法。这些方法会到Animal的原型上。
  2. //另外这边不仅支持混入{},还支持混入Function
  3. var Animal = Class.extend({
  4. init:function(opts){
  5. this.msg = opts.msg
  6. this.type = "animal"
  7. },
  8. say:function(){
  9. alert(this.msg+":i am a "+this.type)
  10. }
  11. })
  12. //继承Animal,并且混入一些方法
  13. var Dog = Animal.extend({
  14. init:function(opts){
  15. //并未实现super方法,直接简单使用父类原型调用即可
  16. Animal.prototype.init.call(this,opts)
  17. //修改了type类型
  18. this.type = "dog"
  19. }
  20. })
  21. //new Animal({msg:'hello'}).say()
  22. new Dog({msg:'hi'}).say()

这边要强调的是,继承的父类都是一个也就是单继承。但是可以通过extend实现多重混入。详见下面用法。使用很简单,超级父类具有extend方法,可以继承出一个子类。子类也具有extend方法。

有了这个类的扩展,我们可以这么编写代码了:

  1. var TextCount = Class.extend({
  2. init:function(config){
  3. this.input = $(config.id);
  4. this._bind();
  5. this.render();
  6. },
  7. render:function() {
  8. var num = this._getNum();
  9. if ($('#J_input_count').length == 0) {
  10. this.input.after('<span id="J_input_count"></span>');
  11. };
  12. $('#J_input_count').html(num+'个字');
  13. },
  14. _getNum:function(){
  15. return this.input.val().length;
  16. },
  17. _bind:function(){
  18. var self = this;
  19. self.input.on('keyup',function(){
  20. self.render();
  21. });
  22. }
  23. })
  24. $(function() {
  25. new TextCount({
  26. id:"#J_input"
  27. });
  28. })

抽象出base这边可能还没看见class的真正好处,不急我们继续往下。

可以看到,我们的组件有些方法,是大部分组件都会有的。

  • 比如init用来初始化属性。
  • 比如render用来处理渲染的逻辑。
  • 比如bind用来处理事件的绑定。

当然这也是一种约定俗成的规范了。如果大家全部按照这种风格来写代码,开发大规模组件库就变得更加规范,相互之间配合也更容易。

这个时候面向对象的好处就来了,我们抽象出一个Base类。其他组件编写时都继承它。

  1. var Base = Class.extend({
  2. init:function(config){
  3. //自动保存配置项
  4. this.__config = config
  5. this.bind()
  6. this.render()
  7. },
  8. //可以使用get来获取配置项
  9. get:function(key){
  10. return this.__config[key]
  11. },
  12. //可以使用set来设置配置项
  13. set:function(key,value){
  14. this.__config[key] = value
  15. },
  16. bind:function(){
  17. },
  18. render:function() {
  19. },
  20. //定义销毁的方法,一些收尾工作都应该在这里
  21. destroy:function(){
  22. }
  23. })

于是我们可以这么写代码:base类主要把组件的一般性内容都提取了出来,这样我们编写组件时可以直接继承base类,覆盖里面的bind和render方法。

  1. var TextCount = Base.extend({
  2. _getNum:function(){
  3. return this.get('input').val().length;
  4. },
  5. bind:function(){
  6. var self = this;
  7. self.get('input').on('keyup',function(){
  8. self.render();
  9. });
  10. },
  11. render:function() {
  12. var num = this._getNum();
  13. if ($('#J_input_count').length == 0) {
  14. this.get('input').after('<span id="J_input_count"></span>');
  15. };
  16. $('#J_input_count').html(num+'个字');
  17. }
  18. })
  19. $(function() {
  20. new TextCount({
  21. //这边直接传input的节点了,因为属性的赋值都是自动的。
  22. input:$("#J_input")
  23. });
  24. })

事实上,这边的init,bind,render就已经有了点生命周期的影子,但凡是组件都会具有这几个阶段,初始化,绑定事件,以及渲染。当然这边还可以加一个destroy销毁的方法,用来清理现场。可以看到我们直接实现一些固定的方法,bind,render就行了。其他的base会自动处理(这里只是简单处理了配置属性的赋值)。

此外为了方便,这边直接变成了传递input的节点。因为属性赋值自动化了,一般来说这种情况下都是使用getter,setter来处理。这边就不详细展开了。

引入事件机制(观察者模式)

有了base应该说我们编写组件更加的规范化,体系化了。下面我们继续深挖。

还是上面的那个例子,如果我们希望输入字的时候超过5个字就弹出警告。该怎么办呢。

小白可能会说,那简单啊直接改下bind方法:

  1. var TextCount = Base.extend({
  2. ...
  3. bind:function(){
  4. var self = this;
  5. self.get('input').on('keyup',function(){
  6. if(self._getNum() > 5){
  7. alert('超过了5个字了。。。')
  8. }
  9. self.render();
  10. });
  11. },
  12. ...
  13. })

这个时候就要引入事件机制,也就是经常说的观察者模式。的确也是一种方法,但是太low了,代码严重耦合。当这种需求特别特别多,代码会越来越乱。

注意这边的事件机制跟平时的浏览器那些事件不是一回事,要分开来看。

什么是观察者模式呢,官方的解释就不说了,直接拿这个例子来说。

想象一下base是个机器人会说话,他会一直监听输入的字数并且汇报出去(通知)。而你可以把耳朵凑上去,听着他的汇报(监听)。发现字数超过5个字了,你就做些操作。

所以这分为两个部分,一个是通知,一个是监听。

假设通知是 fire方法,监听是on。于是我们可以这么写代码:

  1. var TextCount = Base.extend({
  2. ...
  3. bind:function(){
  4. var self = this;
  5. self.get('input').on('keyup',function(){
  6. //通知,每当有输入的时候,就报告出去。
  7. self.fire('Text.input',self._getNum())
  8. self.render();
  9. });
  10. },
  11. ...
  12. })
  13. $(function() {
  14. var t = new TextCount({
  15. input:$("#J_input")
  16. });
  17. //监听这个输入事件
  18. t.on('Text.input',function(num){
  19. //可以获取到传递过来的值
  20. if(num>5){
  21. alert('超过了5个字了。。。')
  22. }
  23. })
  24. })

下面我们看看怎么实现这套事件机制。fire用来触发一个事件,可以传递数据。而on用来添加一个监听。这样组件里面只负责把一些关键的事件抛出来,至于具体的业务逻辑都可以添加监听来实现。没有事件的组件是不完整的。

我们首先抛开base,想想怎么实现一个具有这套机制的类。

  1. //辅组函数,获取数组里某个元素的索引 index
  2. var _indexOf = function(array,key){
  3. if (array === null) return -1
  4. var i = 0, length = array.length
  5. for (; i < length; i++) if (array[i] === item) return i
  6. return -1
  7. }
  8. var Event = Class.extend({
  9. //添加监听
  10. on:function(key,listener){
  11. //this.__events存储所有的处理函数
  12. if (!this.__events) {
  13. this.__events = {}
  14. }
  15. if (!this.__events[key]) {
  16. this.__events[key] = []
  17. }
  18. if (_indexOf(this.__events,listener) === -1 && typeof listener === 'function') {
  19. this.__events[key].push(listener)
  20. }
  21. return this
  22. },
  23. //触发一个事件,也就是通知
  24. fire:function(key){
  25. if (!this.__events || !this.__events[key]) return
  26. var args = Array.prototype.slice.call(arguments, 1) || []
  27. var listeners = this.__events[key]
  28. var i = 0
  29. var l = listeners.length
  30. for (i; i < l; i++) {
  31. listeners[i].apply(this,args)
  32. }
  33. return this
  34. },
  35. //取消监听
  36. off:function(key,listener){
  37. if (!key && !listener) {
  38. this.__events = {}
  39. }
  40. //不传监听函数,就去掉当前key下面的所有的监听函数
  41. if (key && !listener) {
  42. delete this.__events[key]
  43. }
  44. if (key && listener) {
  45. var listeners = this.__events[key]
  46. var index = _indexOf(listeners, listener)
  47. (index > -1) && listeners.splice(index, 1)
  48. }
  49. return this;
  50. }
  51. })
  52. var a = new Event()
  53. //添加监听 test事件
  54. a.on('test',function(msg){
  55. alert(msg)
  56. })
  57. //触发 test事件
  58. a.fire('test','我是第一次触发')
  59. a.fire('test','我又触发了')
  60. a.off('test')
  61. a.fire('test','你应该看不到我了')

这个时候面向对象的好处就来了,如果我们希望base拥有事件机制。只需要这么写:实现起来并不复杂,只要使用this.__events存下所有的监听函数。在fire的时候去找到并且执行就行了。

  1. var Base = Class.extend(Event,{
  2. ...
  3. destroy:function(){
  4. //去掉所有的事件监听
  5. this.off()
  6. }
  7. })
  8. //于是可以
  9. //var a = new Base()
  10. // a.on(xxx,fn)
  11. //
  12. // a.fire()

有了事件机制我们可以把组件内部很多状态暴露出来,比如我们可以在set方法中抛出一个事件,这样每次属性变更的时候我们都可以监听到。是的只要extend的时候多混入一个Event,这样Base或者它的子类生成的对象都会自动具有事件机制。

到这里为止,我们的base类已经像模像样了,具有了init,bind,render,destroy方法来表示组件的各个关键过程,并且具有了事件机制。基本上已经可以很好的来开发组件了。

更进一步,richbase

我们还可以继续深挖。看看我们的base,还差些什么。首先浏览器的事件监听还很落后,需要用户自己在bind里面绑定,再然后现在的TextCount里面还存在dom操作,也没有自己的模板机制。这都是需要扩展的,于是我们在base的基础上再继承出一个richbase用来实现更完备的组件基类。

主要实现这些功能:

  • 事件代理:不需要用户自己去找dom元素绑定监听,也不需要用户去关心什么时候销毁。
  • 模板渲染:用户不需要覆盖render方法,而是覆盖实现setUp方法。可以通过在setUp里面调用render来达到渲染对应html的目的。
  • 单向绑定:通过setChuckdata方法,更新数据,同时会更新html内容,不再需要dom操作。

我们看下我们实现richbase后怎么写组件:

  1. var TextCount = RichBase.extend({
  2. //事件直接在这里注册,会代理到parentNode节点,parentNode节点在下面指定
  3. EVENTS:{
  4. //选择器字符串,支持所有jQuery风格的选择器
  5. 'input':{
  6. //注册keyup事件
  7. keyup:function(self,e){
  8. //单向绑定,修改数据直接更新对应模板
  9. self.setChuckdata('count',self._getNum())
  10. }
  11. }
  12. },
  13. //指定当前组件的模板
  14. template:'<span id="J_input_count"><%= count %>个字</span>',
  15. //私有方法
  16. _getNum:function(){
  17. return this.get('input').val().length || 0
  18. },
  19. //覆盖实现setUp方法,所有逻辑写在这里。最后可以使用render来决定需不需要渲染模板
  20. //模板渲染后会append到parentNode节点下面,如果未指定,会append到document.body
  21. setUp:function(){
  22. var self = this;
  23. var input = this.get('parentNode').find('#J_input')
  24. self.set('input',input)
  25. var num = this._getNum()
  26. //赋值数据,渲染模板,选用。有的组件没有对应的模板就可以不调用这步。
  27. self.render({
  28. count:num
  29. })
  30. }
  31. })
  32. $(function() {
  33. //传入parentNode节点,组件会挂载到这个节点上。所有事件都会代理到这个上面。
  34. new TextCount({
  35. parentNode:$("#J_test_container")
  36. });
  37. })
  38. /**对应的html,做了些修改,主要为了加上parentNode,这边就是J_test_container
  39. <div id="J_test_container">
  40. <input type="text" id="J_input"/>
  41. </div>
  42. */

看下上面的用法,可以看到变得更简单清晰了:

  • 事件不需要自己绑定,直接注册在EVENTS属性上。程序会自动将事件代理到parentNode上。
  • 引入了模板机制,使用template规定组件的模板,然后在setUp里面使用render(data)的方式渲染模板,程序会自动帮你append到parentNode下面。
  • 单向绑定,无需操作dom,后面要改动内容,不需要操作dom,只需要调用setChuckdata(key,新的值),选择性的更新某个数据,相应的html会自动重新渲染。

下面我们看下richebase的实现:

  1. var RichBase = Base.extend({
  2. EVENTS:{},
  3. template:'',
  4. init:function(config){
  5. //存储配置项
  6. this.__config = config
  7. //解析代理事件
  8. this._delegateEvent()
  9. this.setUp()
  10. },
  11. //循环遍历EVENTS,使用jQuery的delegate代理到parentNode
  12. _delegateEvent:function(){
  13. var self = this
  14. var events = this.EVENTS || {}
  15. var eventObjs,fn,select,type
  16. var parentNode = this.get('parentNode') || $(document.body)
  17. for (select in events) {
  18. eventObjs = events[select]
  19. for (type in eventObjs) {
  20. fn = eventObjs[type]
  21. parentNode.delegate(select,type,function(e){
  22. fn.call(null,self,e)
  23. })
  24. }
  25. }
  26. },
  27. //支持underscore的极简模板语法
  28. //用来渲染模板,这边是抄的underscore的。非常简单的模板引擎,支持原生的js语法
  29. _parseTemplate:function(str,data){
  30. /**
  31. * http://ejohn.org/blog/javascript-micro-templating/
  32. * https://github.com/jashkenas/underscore/blob/0.1.0/underscore.js#L399
  33. */
  34. var fn = new Function('obj',
  35. 'var p=[],print=function(){p.push.apply(p,arguments);};' +
  36. 'with(obj){p.push(\'' + str
  37. .replace(/[\r\t\n]/g, " ")
  38. .split("<%").join("\t")
  39. .replace(/((^|%>)[^\t]*)'/g, "$1\r")
  40. .replace(/\t=(.*?)%>/g, "',$1,'")
  41. .split("\t").join("');")
  42. .split("%>").join("p.push('")
  43. .split("\r").join("\\'") +
  44. "');}return p.join('');")
  45. return data ? fn(data) : fn
  46. },
  47. //提供给子类覆盖实现
  48. setUp:function(){
  49. this.render()
  50. },
  51. //用来实现刷新,只需要传入之前render时的数据里的key还有更新值,就可以自动刷新模板
  52. setChuckdata:function(key,value){
  53. var self = this
  54. var data = self.get('__renderData')
  55. //更新对应的值
  56. data[key] = value
  57. if (!this.template) return;
  58. //重新渲染
  59. var newHtmlNode = $(self._parseTemplate(this.template,data))
  60. //拿到存储的渲染后的节点
  61. var currentNode = self.get('__currentNode')
  62. if (!currentNode) return;
  63. //替换内容
  64. currentNode.replaceWith(newHtmlNode)
  65. self.set('__currentNode',newHtmlNode)
  66. },
  67. //使用data来渲染模板并且append到parentNode下面
  68. render:function(data){
  69. var self = this
  70. //先存储起来渲染的data,方便后面setChuckdata获取使用
  71. self.set('__renderData',data)
  72. if (!this.template) return;
  73. //使用_parseTemplate解析渲染模板生成html
  74. //子类可以覆盖这个方法使用其他的模板引擎解析
  75. var html = self._parseTemplate(this.template,data)
  76. var parentNode = this.get('parentNode') || $(document.body)
  77. var currentNode = $(html)
  78. //保存下来留待后面的区域刷新
  79. //存储起来,方便后面setChuckdata获取使用
  80. self.set('__currentNode',currentNode)
  81. parentNode.append(currentNode)
  82. },
  83. destroy:function(){
  84. var self = this
  85. //去掉自身的事件监听
  86. self.off()
  87. //删除渲染好的dom节点
  88. self.get('__currentNode').remove()
  89. //去掉绑定的代理事件
  90. var events = self.EVENTS || {}
  91. var eventObjs,fn,select,type
  92. var parentNode = self.get('parentNode')
  93. for (select in events) {
  94. eventObjs = events[select]
  95. for (type in eventObjs) {
  96. fn = eventObjs[type]
  97. parentNode.undelegate(select,type,fn)
  98. }
  99. }
  100. }
  101. })

主要做了两件事,一个就是事件的解析跟代理,全部代理到parentNode上面。另外就是把render抽出来,用户只需要实现setUp方法。如果需要模板支持就在setUp里面调用render来渲染模板,并且可以通过setChuckdata来刷新模板,实现单向绑定。

结语

有了richbase,基本上组件开发就没啥问题了。但是我们还是可以继续深挖下去。

比如组件自动化加载渲染,局部刷新,比如父子组件的嵌套,再比如双向绑定,再比如实现ng-click这种风格的事件机制。

当然这些东西已经不属于组件里面的内容了。再进一步其实已经是一个框架了。实际上最近比较流行的react,ploymer还有我们的brix等等都是实现了这套东西。

原文:http://purplebamboo.github.io/2015/03/16/javascript-component/

发表评论

表情:
评论列表 (有 0 条评论,263人围观)

还没有评论,来说两句吧...

相关阅读

    相关 Android

    随着时间的推移,项目变得越来越臃肿,随便修改一些东西,重新编译一次,也要几分钟,极大影响开发效率。 故在一个月前,决定引入组件化,来缓解这种情况。 先对组件化的优势和主

    相关 Layui

    时代的车轮滚滚向前,哪些属于每个时代的英雄变成了那个时代的印记,就像贤心说的 这是一种带有热量的冰冷感! 我们选择了 IT 这个行业,自然希望能够在这里走得更远。而那些大神创

    相关 Android和插开发

    项目发展到一定程度,就必须进行模块的拆分。模块化是一种指导理念,其核心思想就是分而治之、降低耦合。而在 Android 工程实践,目前有两种途径,一个是组件化,一个是插件化。

    相关 javascript

    作为一名前端工程师,写组件的能力至关重要。虽然javascript经常被人嘲笑是个小玩具,但是在一代代大牛的前仆后继的努力下,渐渐的也摸索了一套组件的编写方式。 下面我们来谈