【shadow dom入UI】web components思想如何應用於實際專案

葉小釵發表於2014-12-17

回顧

經過昨天的優化處理(【前端優化之拆分CSS】前端三劍客的分分合合),我們在UI一塊做了幾個關鍵動作:

① CSS入UI

② CSS作為元件的一個節點而存在,並且會被“格式化”,即選擇器帶id字首,形成的元件如圖所示:

這樣做基本可以規避css汙染的問題,解決絕大多數問題,但是更優的方案總是存在,比如web components中的shadow dom!

javascript的元件基本是不可重用的,幾個核心原因是:

① 元件例項與例項之間的html、css、Javascript很容易互相汙染(id汙染、class汙染、js變數汙染......)
② 一個元件依賴於HTML、CSS、Javascript,而三者之間是分離的,而元件內部控制於js,更改後外部可能出問題
通過昨天的處理,我們將一個元件所用到的全部合到了一起,卻又分離成了三個檔案:

① ui.js
② ui.html
③ ui.css

這種處理一方面透露著解耦的思想,另一方面體現著解依賴的想法,在這個基礎上想引入shadow dom技術,變得非常輕易。

什麼是shadow dom

shadow dom是一種瀏覽器行為,他允許在document文件中渲染時插入一個獨立的dom子樹,但這個dom樹與主dom樹完全分離的,不會互相影響。
從一張圖來看:

shadow dom事實上也是一個文件碎片,我們甚至可以將之作為jQuery包裝物件處理:

存在在shadow dom中的元素是不可被選擇器找到的,比如這種做法會徒勞無功:

$('沙箱中的一個元素') => []

另一個比較重要的差別是,外部為元件定義的事件,比如click事件的e.target便只能是元件div了,也就是這個元件事實上只有一層,一個標籤,內部的結構不會被暴露!

引入框架

原來我們的元件是這樣的結構:

1 <div id="ui-view-16" style="">
2   <div class="cm-num-adjust">
3     <div class="cm-num-adjust">
4       <span class="cm-adjust-minus js_num_minus disabled "></span><span class="cm-adjust-view js_cur_num "
5         contenteditable="true">1個</span> <span class="cm-adjust-plus js_num_plus "></span>
6     </div>
7   </div>
8 </div>

框架會主動建立一個包裹層,包裹層內才是元件dom,經過昨天的處理,元件變成了這樣:

 1 <!--元件生成的包裹層-->
 2 <div id="wrapper" >
 3   <!--元件格式化後的樣式-->
 4   <style>
 5   #wrapper { ......}
 6   </style>
 7 
 8   <!--元件真實的dom結構-->
 9   <div></div>
10 <div>

如果這裡我們使用shadow dom技術的話,整個結構會變成這樣:

1 <div id="wrapper">
2   #shadow-root
3   <style></style>
4   <div>
5   </div>
6 <div>

元件自動建立的dom包裹層,裡面神馬都沒有了,因為事件代理是進不去的,所以開啟shadow dom方式的元件需要將事件繫結至shadow節點

當然,並不是所有瀏覽器都支援shadow dom技術,當此之時,也不是所有的shadow dom都合適;所以UI基類需要做一個開關,最大限度的避免生產風險,而又能引入新的技術

 1 //與模板對應的css檔案,預設不存在,需要各個元件複寫
 2   this.uiStyle = null;
 3 
 4   //儲存樣式格式化結束的字串
 5   //      this.formateStyle = null;
 6 
 7   //儲存shadow dom的引用,用於事件代理
 8   this.shadowDom = null;
 9   this.shadowStyle = null;
10   this.shadowRoot = null;
11 
12   //框架統一開關,是否開啟shadow dom
13   this.openShadowDom = true;
14 
15 //      this.openShadowDom = false;
16 
17   //不支援建立介面便關閉,也許有其它因素導致,這個後期已介面放出
18   if (!this.wrapper[0].createShadowRoot) {
19     this.openShadowDom = false;
20   }

基類會多出幾個屬性處理,shadow邏輯,然後在建立UI dom節點時候需要進行特殊處理

 1 createRoot: function (html) {
 2 
 3   this.$el = $('<div class="view" style="display: none; " id="' + this.id + '"></div>');
 4   var style = this.getInlineStyle();
 5 
 6   //如果存在shadow dom介面,並且框架開啟了shadow dom
 7   if (this.openShadowDom) {
 8     //在框架建立的子元素層面建立沙箱
 9     this.shadowRoot = $(this.$el[0].createShadowRoot());
10 
11     this.shadowDom = $('<div class="js_shadow_root">' + html + '</div>');
12     this.shadowStyle = $(style);
13 
14     //開啟shadow dom情況下,元件需要被包裹起來
15     this.shadowRoot.append(this.shadowStyle);
16     this.shadowRoot.append(this.shadowDom);
17 
18   } else {
19 
20     this.$el.html(style + html);
21   }
22 },

在開啟shadow dom功能的情況下,便會為根節點建立shadow root,將style節點與html節點裝載進去,這個時候UI結構基本出來了,事件便繫結至shadow root即可,這裡是全部程式碼:

  1 define([], function () {
  2 
  3   var getBiggerzIndex = (function () {
  4     var index = 3000;
  5     return function (level) {
  6       return level + (++index);
  7     };
  8   })();
  9 
 10   return _.inherit({
 11     propertys: function () {
 12       //模板狀態
 13       this.wrapper = $('body');
 14       this.id = _.uniqueId('ui-view-');
 15 
 16       this.template = '';
 17 
 18       //與模板對應的css檔案,預設不存在,需要各個元件複寫
 19       this.uiStyle = null;
 20 
 21       //儲存樣式格式化結束的字串
 22       //      this.formateStyle = null;
 23 
 24       //儲存shadow dom的引用,用於事件代理
 25       this.shadowDom = null;
 26       this.shadowStyle = null;
 27       this.shadowRoot = null;
 28 
 29       //框架統一開關,是否開啟shadow dom
 30       this.openShadowDom = true;
 31 
 32 //      this.openShadowDom = false;
 33 
 34       //不支援建立介面便關閉,也許有其它因素導致,這個後期已介面放出
 35       if (!this.wrapper[0].createShadowRoot) {
 36         this.openShadowDom = false;
 37       }
 38 
 39       this.datamodel = {};
 40       this.events = {};
 41 
 42       //自定義事件
 43       //此處需要注意mask 繫結事件前後問題,考慮scroll.radio外掛型別的mask應用,考慮元件通訊
 44       this.eventArr = {};
 45 
 46       //初始狀態為例項化
 47       this.status = 'init';
 48 
 49       this.animateShowAction = null;
 50       this.animateHideAction = null;
 51 
 52       //      this.availableFn = function () { }
 53 
 54     },
 55 
 56     on: function (type, fn, insert) {
 57       if (!this.eventArr[type]) this.eventArr[type] = [];
 58 
 59       //頭部插入
 60       if (insert) {
 61         this.eventArr[type].splice(0, 0, fn);
 62       } else {
 63         this.eventArr[type].push(fn);
 64       }
 65     },
 66 
 67     off: function (type, fn) {
 68       if (!this.eventArr[type]) return;
 69       if (fn) {
 70         this.eventArr[type] = _.without(this.eventArr[type], fn);
 71       } else {
 72         this.eventArr[type] = [];
 73       }
 74     },
 75 
 76     trigger: function (type) {
 77       var _slice = Array.prototype.slice;
 78       var args = _slice.call(arguments, 1);
 79       var events = this.eventArr;
 80       var results = [], i, l;
 81 
 82       if (events[type]) {
 83         for (i = 0, l = events[type].length; i < l; i++) {
 84           results[results.length] = events[type][i].apply(this, args);
 85         }
 86       }
 87       return results;
 88     },
 89 
 90     bindEvents: function () {
 91       var events = this.events;
 92       var el = this.$el;
 93       if (this.openShadowDom) el = this.shadowRoot;
 94 
 95       if (!(events || (events = _.result(this, 'events')))) return this;
 96       this.unBindEvents();
 97 
 98       // 解析event引數的正則
 99       var delegateEventSplitter = /^(\S+)\s*(.*)$/;
100       var key, method, match, eventName, selector;
101 
102       // 做簡單的字串資料解析
103       for (key in events) {
104         method = events[key];
105         if (!_.isFunction(method)) method = this[events[key]];
106         if (!method) continue;
107 
108         match = key.match(delegateEventSplitter);
109         eventName = match[1], selector = match[2];
110         method = _.bind(method, this);
111         eventName += '.delegateUIEvents' + this.id;
112 
113         if (selector === '') {
114           el.on(eventName, method);
115         } else {
116           el.on(eventName, selector, method);
117         }
118       }
119 
120       return this;
121     },
122 
123     unBindEvents: function () {
124       var el = this.$el;
125       if (this.openShadowDom) el = this.shadowRoot;
126 
127       el.off('.delegateUIEvents' + this.id);
128       return this;
129     },
130 
131     createRoot: function (html) {
132 
133       this.$el = $('<div class="view" style="display: none; " id="' + this.id + '"></div>');
134       var style = this.getInlineStyle();
135 
136       //如果存在shadow dom介面,並且框架開啟了shadow dom
137       if (this.openShadowDom) {
138         //在框架建立的子元素層面建立沙箱
139         this.shadowRoot = $(this.$el[0].createShadowRoot());
140 
141         this.shadowDom = $('<div class="js_shadow_root">' + html + '</div>');
142         this.shadowStyle = $(style);
143 
144         //開啟shadow dom情況下,元件需要被包裹起來
145         this.shadowRoot.append(this.shadowStyle);
146         this.shadowRoot.append(this.shadowDom);
147 
148       } else {
149 
150         this.$el.html(style + html);
151       }
152     },
153 
154     getInlineStyle: function () {
155       //如果不存在便不予理睬
156       if (!_.isString(this.uiStyle)) return null;
157       var style = this.uiStyle, uid = this.id;
158 
159       //在此處理shadow dom的樣式,直接返回處理結束後的html字串
160       if (!this.openShadowDom) {
161         //建立定製化的style字串,會模擬一個沙箱,該元件樣式不會對外影響,實現原理便是加上#id 字首
162         style = style.replace(/(\s*)([^\{\}]+)\{/g, function (a, b, c) {
163           return b + c.replace(/([^,]+)/g, '#' + uid + ' $1') + '{';
164         });
165       }
166 
167       style = '<style >' + style + '</style>';
168       this.formateStyle = style;
169       return style;
170     },
171 
172     render: function (callback) {
173       var data = this.getViewModel() || {};
174 
175       var html = this.template;
176       if (!this.template) return '';
177       if (data) {
178         html = _.template(this.template)(data);
179       }
180 
181       typeof callback == 'function' && callback.call(this);
182       return html;
183     },
184 
185     //重新整理根據傳入引數判斷是否走onCreate事件
186     //這裡原來的dom會被移除,事件會全部丟失 需要修復*****************************
187     refresh: function (needEvent) {
188       var html = '';
189       this.resetPropery();
190       //如果開啟了沙箱便只能重新渲染了
191       if (needEvent) {
192         this.create();
193       } else {
194         html = this.render();
195         if (this.openShadowDom) {
196           //將解析後的style與html字串裝載進沙箱
197           //*************
198           this.shadowDom.html(html);
199         } else {
200           this.$el.html(this.formateStyle + html);
201         }
202       }
203       this.initElement();
204       if (this.status != 'hide') this.show();
205       this.trigger('onRefresh');
206     },
207 
208     _isAddEvent: function (key) {
209       if (key == 'onCreate' || key == 'onPreShow' || key == 'onShow' || key == 'onRefresh' || key == 'onHide')
210         return true;
211       return false;
212     },
213 
214     setOption: function (options) {
215       //這裡可以寫成switch,開始沒有想到有這麼多分支
216       for (var k in options) {
217         if (k == 'datamodel' || k == 'events') {
218           _.extend(this[k], options[k]);
219           continue;
220         } else if (this._isAddEvent(k)) {
221           this.on(k, options[k])
222           continue;
223         }
224         this[k] = options[k];
225       }
226       //      _.extend(this, options);
227     },
228 
229     initialize: function (opts) {
230       this.propertys();
231       this.setOption(opts);
232       this.resetPropery();
233       //新增系統級別事件
234       this.addEvent();
235       //開始建立dom
236       this.create();
237       this.addSysEvents();
238 
239       this.initElement();
240 
241     },
242 
243     //內部重置event,加入全域性控制類事件
244     addSysEvents: function () {
245       if (typeof this.availableFn != 'function') return;
246       this.removeSysEvents();
247       this.$el.on('click.system' + this.id, $.proxy(function (e) {
248         if (!this.availableFn()) {
249           e.preventDefault();
250           e.stopImmediatePropagation && e.stopImmediatePropagation();
251         }
252       }, this));
253     },
254 
255     removeSysEvents: function () {
256       this.$el.off('.system' + this.id);
257     },
258 
259     $: function (selector) {
260       return this.openShadowDom ? this.shadowDom.find(selector) : this.$el.find(selector);
261     },
262 
263     //提供屬性重置功能,對屬性做檢查
264     resetPropery: function () {
265     },
266 
267     //各事件註冊點,用於被繼承
268     addEvent: function () {
269     },
270 
271     create: function () {
272       this.trigger('onPreCreate');
273       this.createRoot(this.render());
274 
275       this.status = 'create';
276       this.trigger('onCreate');
277     },
278 
279     //例項化需要用到到dom元素
280     initElement: function () { },
281 
282     show: function () {
283       if (!this.wrapper[0] || !this.$el[0]) return;
284       //如果包含就不要亂搞了
285       if (!$.contains(this.wrapper[0], this.$el[0])) {
286         this.wrapper.append(this.$el);
287       }
288 
289       this.trigger('onPreShow');
290 
291       if (typeof this.animateShowAction == 'function')
292         this.animateShowAction.call(this, this.$el);
293       else
294         this.$el.show();
295 
296       this.status = 'show';
297       this.bindEvents();
298       this.trigger('onShow');
299     },
300 
301     hide: function () {
302       if (!this.$el || this.status !== 'show') return;
303 
304       this.trigger('onPreHide');
305 
306       if (typeof this.animateHideAction == 'function')
307         this.animateHideAction.call(this, this.$el);
308       else
309         this.$el.hide();
310 
311       this.status = 'hide';
312       this.unBindEvents();
313       this.removeSysEvents();
314       this.trigger('onHide');
315     },
316 
317     destroy: function () {
318       this.status = 'destroy';
319       this.unBindEvents();
320       this.removeSysEvents();
321       this.$el.remove();
322       this.trigger('onDestroy');
323       delete this;
324     },
325 
326     getViewModel: function () {
327       return this.datamodel;
328     },
329 
330     setzIndexTop: function (el, level) {
331       if (!el) el = this.$el;
332       if (!level || level > 10) level = 0;
333       level = level * 1000;
334       el.css('z-index', getBiggerzIndex(level));
335     }
336 
337   });
338 
339 });
View Code

基類程式碼改動結束,一旦開啟shadow dom開關,每個元件便會走shadow邏輯,否則走原邏輯:

關閉介面的話,又變成了這個樣子了:

引入shadow dom的意義

web components的提出,旨在解決UI重用的問題、解決相同功能介面各異的問題,大規模的用於生產似乎不太接地氣,但是shadow dom技術對於webapp卻是個好東西。

上文還只是在UI層面上應用shadow dom技術,webapp中每個view頁面片如果可以應用shadow dom技術的話,各個View將不必考慮id重複汙染、css樣式汙染、javascript變數汙染,並且效率還比原來高多了,因為對於頁面來說,他就僅僅是一個標籤而已,如此一來,大規模的webapp的網站可能真的會到來了!

demo地址:http://yexiaochai.github.io/cssui/demo/debug.html#num

程式碼地址:https://github.com/yexiaochai/cssui/tree/gh-pages

博主正在學習web components技術,並且嘗試將之用於專案,文中有誤或者有不妥的地方請您提出

相關文章