框架是如何煉成的 —— 揭秘前端頂級框架的底層實現原理


譯者注:本文原文 Revealing the Magic of JavaScript ,原標題“揭秘JavaScript魔法”,本文深入淺出,揭示了幾個前沿框架如jQuery、angularJs、EmberJs和React的幾個核心功能點的實現技巧,無論是對前端菜鳥還是老鳥,相信都會有一定的啟迪。鄙人精力和能力有限,如有錯誤或生澀之處,還請指出和多多包涵。

我們每天都使用大量的前端庫和框架,這些各種各樣的庫和框架已經成為我們日常工作的一部分,我們之所以使用他們,是因為我們不想重新造輪子,即使我們不明白它們的底層是怎么回事,在這篇文章中,我將揭示流行框架中發生了哪些神奇的過程,同時我們也會探討如何自己去實現。

使用字符串生成 DOM

隨着單頁應用的興起,我們正在用JavaScript做越來越多的事情,我們的應用程序邏輯的很大一部分已經被移植到瀏覽器,在頁面上動態產生或或替換元素變得非常頻繁,比如如下的代碼:

    var text = $('<div>Simple text</div>'); $('body').append(text); 

這段代碼的結果是添加一個div元素到文檔的body標簽。使用jQuery,這個操作只需一行代碼,如果沒有jQuery,代碼有點復雜,但是也不會很多:

    var stringToDom = function(str) { var temp = document.createElement('div'); temp.innerHTML = str; return temp.childNodes[0]; } var text = stringToDom('<div>Simple text</div>'); document.querySelector('body').appendChild(text); 

我們定義了工具方法 stringToDom,它創建1個臨時的 <div> 元素,然后設置它的 innerHTML 屬性為傳入的參數,並在最后簡單地返回其第一個孩子節點。它們的工作方式相同,然而,如果我們用下面的代碼,將會觀察到不一樣的結果:

    var tableRow = $('<tr><td>Simple text</td></tr>');//使用jquery $('body').append(tableRow); var tableRow = stringToDom('<tr><td>Simple text</td></tr>');//使用我們自己的方法 document.querySelector('body').appendChild(tableRow); 

用肉眼觀察頁面,看起來沒有差異,但是,如果我們使用 Chrome Developer ,我們會得到一個有趣的結果:

似乎我們的 stringToDom 函數創建的只是一個文本節點,而不是想要的 <tr> 標簽,但同時 jQuery 的代碼卻做到了這一點,這是腫么回事?原來含有該元素的 HTML 字符串是通過瀏覽器解析器運行,而該解析器忽略了沒有正確上下文的 HTML,我們得到的只是一個文本節點,一個不在表格中的行顯然被瀏覽器認為是非法的。

jQuery通過創建合適的上下文並提取其中需要的部分成功地解決了這個問題,如果我們查看它的源代碼,可以看到這樣一個 map:

    var wrapMap = { option: [1, '<select multiple="multiple">', '</select>'], legend: [1, '<fieldset>', '</fieldset>'], area: [1, '<map>', '</map>'], param: [1, '<object>', '</object>'], thead: [1, '<table>', '</table>'], tr: [2, '<table><tbody>', '</tbody></table>'], col: [2, '<table><tbody></tbody><colgroup>', '</colgroup></table>'], td: [3, '<table><tbody><tr>', '</tr></tbody></table>'], _default: [1, '<div>', '</div>'] }; wrapMap.optgroup = wrapMap.option; wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; 

所有需要特殊處理的元素都分配了一個數組,其主要思路是構建正確的 DOM 元素,並依賴於嵌套來獲取所需要的層級元素,例如,對於 <tr> 元素,需要創建一個 <table> 節點及子節點 <TBODY> ,因此,嵌套層次有兩級。

有一個 map 之后,我們要找出最終想要什么樣的標簽,下面的代碼使用正則表達式從<tr><td>Simple text</td></tr> 提取出tr:

    var match = /&lt;\s*\w.*?&gt;/g.exec(str); var tag = match[0].replace(/&lt;/g, '').replace(/&gt;/g, ''); 

剩下的是找到正確的上下文並返回DOM元素,下面是函數 stringToDom 的最終變種:

    var stringToDom = function(str) { var wrapMap = { option: [1, '<select multiple="multiple">', '</select>'], legend: [1, '<fieldset>', '</fieldset>'], area: [1, '<map>', '</map>'], param: [1, '<object>', '</object>'], thead: [1, '<table>', '</table>'], tr: [2, '<table><tbody>', '</tbody></table>'], col: [2, '<table><tbody></tbody><colgroup>', '</colgroup></table>'], td: [3, '<table><tbody><tr>', '</tr></tbody></table>'], _default: [1, '<div>', '</div>'] }; wrapMap.optgroup = wrapMap.option; wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; var element = document.createElement('div'); var match = /<\s*\w.*?>/g.exec(str); if(match != null) { var tag = match[0].replace(/</g, '').replace(/>/g, ''); var map = wrapMap[tag] || wrapMap._default, element; str = map[1] + str + map[2]; element.innerHTML = str; // Descend through wrappers to the right content var j = map[0]+1; while(j--) { element = element.lastChild; } } else { // if only text is passed element.innerHTML = str; element = element.lastChild; } return element; } 

請注意,我們會檢查是否在字符串中存在標簽 —— match != NULL,如果不是,則簡單返回一個文本節點,我們仍然使用了一個臨時的<div>,但這次我們傳遞了正確的DOM標簽,使瀏覽器可以創建一個有效的 DOM 樹,最終通過一個while循環,不斷地遞歸,直到找到我們想要的標簽。

下面是在 CodePen 顯示的執行結果:

<tr><td>Simple text</td></tr>

接下來我們來探討angularjs的依賴注入。

探討 Angularjs 的依賴注入

當我們開始使用 AngularJS 時,最令人印象深刻的便是雙向數據綁定,但我們注意到的第二件事便是其神奇的依賴注入,下面是一個簡單的例子:

    function TodoCtrl($scope, $http) { $http.get('users/users.json').success(function(data) { $scope.users = data; }); } 

這是一個典型的 AngularJS 控制器,它執行 HTTP 請求,從一個 JSON 文件讀取數據,並把它傳遞給目前的上下文,我們並沒有執行 TodoCtrl 函數,甚至沒有傳遞任何參數的機會,框架幫我們做了,這些 $scope 和 $HTTP 變量從哪里來的?這是一個超級酷的功能,像黑魔法一樣,讓我們來看看它是如何實現的。

現在假設我們的系統有一個顯示用戶信息的 JavaScript 函數,它需要訪問 DOM 元素並放置最終生成的 HTML ,以及一個 Ajax 包裝器來獲取數據,為了簡化這個例子,我們將偽造實體模型數據和 HTTP 請求。

    var dataMockup = ['John', 'Steve', 'David']; var body = document.querySelector('body'); var ajaxWrapper = { get: function(path, cb) { console.log(path + ' requested'); cb(dataMockup); } } 

我們將使用<body>標簽作為內容載體。 ajaxWrapper 是模擬請求的對象,dataMockup 包含了我們的用戶數組。下面是我們將使用的函數:

    var displayUsers = function(domEl, ajax) { ajax.get('/api/users', function(users) { var html = ''; for(var i=0; i < users.length; i++) { html += '<p>' + users[i] + '</p>'; } domEl.innerHTML = html; }); } 

顯然,如果我們運行 displayUsers(body,ajaxWrapper) 我們會看到頁面上顯示了3個名字,控制台輸出了/API/users requested ,我們可以說,我們的方法有兩個依賴 —— body 和 ajaxWrapper 。現在我們的想法是讓函數工作時無需傳遞參數,也就是我們要得到相同的結果僅通過調用displayUsers()即可,如果使用現有的代碼去執行,結果將是:

    Uncaught TypeError: Cannot read property 'get' of undefined 

這是正常的,因為 AJAX 參數沒有定義。

大多數提供依賴注入的框架通常都有一個稱為注入器的模塊,使用它需要將依賴注冊,然后,我們的資源再由這個注入器模塊傳遞給應用程序邏輯。

現在我們來創建自己的注入器:

    var injector = { storage: {}, register: function(name, resource) { this.storage[name] = resource; }, resolve: function(target) { } }; 

我們只需要兩個方法:第一個,register,它接受我們的資源(依賴)並在內部存儲;第二個我們接受注入的目標target - 即那些有依賴性,需要接受他們作為參數的函數,這里的關鍵點是注入器不應調用我們的函數,這是我們的工作,我們應該能夠控制,我們的解決方法是在 resolve 方法返回一個包裹了 target 的閉包並調用它。例如:

    resolve: function(target) { return function() { target(); }; } 

使用該方法,我們將有機會來調用函數以及所需的依賴關系,並且,我們未改變應用程序的工作流程,注入器仍然是獨立的東西,並沒有帶任何邏輯。

然而,當傳遞 displayUsers 函數作為 Resolve 方法的參數時並沒有作用:

    displayUsers = injector.resolve(displayUsers); displayUsers(); 

我們仍然得到同樣的錯誤,下一步是要找出什么是 target 的需要,什么是它的依賴?這里是我們從 AngularJS 里挖到的關鍵代碼:

    var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; ... function annotate(fn) { ... fnText = fn.toString().replace(STRIP_COMMENTS, ''); argDecl = fnText.match(FN_ARGS); ... } 

我們刻意忽略那些具體的實現,剩下的代碼是有趣的,函數 annotate 的功能和 resolve 是一樣的東西,它把目標函數源碼轉為字符串,刪除注釋(如果有的話),並提取參數,讓我們的 resolve 函數使用這段代碼並查看結果:

    resolve: function(target) { var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; fnText = target.toString().replace(STRIP_COMMENTS, ''); argDecl = fnText.match(FN_ARGS); console.log(argDecl); return function() { target(); } } 

最終結果是:

如果我們得到 argDecl 數組的第二個元素,我們將找到所需的依賴的名稱。這正是我們需要的,因為有這個我們就能從注入器的 storage 傳送資源,下面是我們覆蓋后的一個可以運行的目標版本:

    resolve: function(target) { var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; fnText = target.toString().replace(STRIP_COMMENTS, ''); argDecl = fnText.match(FN_ARGS)[1].split(/, ?/g); var args = []; for(var i=0; i&lt;argDecl.length; i++) { if(this.storage[argDecl[i]]) { args.push(this.storage[argDecl[i]]); } } return function() { target.apply({}, args); } } 

請注意,我們使用的是 split(/,?/g) 來把字符串 domEl,ajax 轉換為一個數組,之后,我們查詢依賴是否在我們的 storage 對象注冊了,如果是我們則傳遞他們到 target 函數,注入器以外的代碼看起來像這樣:

    injector.register('domEl', body); injector.register('ajax', ajaxWrapper); displayUsers = injector.resolve(displayUsers); displayUsers(); 

這種實現的好處是,我們可以在許多函數注入 DOM 元素和 Ajax warpper ,我們甚至可以像這樣分發我們的應用程序配置,不再需要在類與類之間傳遞各種對象,僅需一個 register 和 resolve 方法即可。

當然,我們的注入器是不完美的,還有一些改進的空間,比如對自定義作用域的支持,target 現在在一個全新的作用域內被調用,但通常我們會想使用我們自定義的,另外我們應該支持傳遞參數給依賴。

如果我們既想保持代碼短小又能正常工作,則注入器將變得更加復雜,正如我們所知道的我們這個縮小版替換了函數名,變量和方法的參數,而且因為我們的邏輯依賴於這些名稱我們需要考慮變通,一個可能的解決方案是再次使用AngularJs:

    displayUsers = injector.resolve(['domEl', 'ajax', displayUsers]); 

與之前僅僅傳遞 displayUsers 不同,現在還傳遞了實際依賴的名字。

現在看看這個例子的最終結果:

John
Steve
David

采用 Ember 的動態屬性

Ember 是時下最流行的框架之一,它具有很多實用的功能,有一個特別有趣 - 動態屬性。總地來說,動態屬性是作為屬性的函數。讓我們來看看從Ember的文檔摘取的一個簡單的例子:

    App.Person = Ember.Object.extend({ firstName: null, lastName: null, fullName: function() { return this.get('firstName') + ' ' + this.get('lastName'); }.property('firstName', 'lastName') }); var ironMan = App.Person.create({ firstName: "Tony", lastName: "Stark" }); ironMan.get('fullName') // "Tony Stark" 

這里有有個包含firstName和lastName屬性的類,其中計算的屬性FullName返回一個包含該人的全名的字符串,奇怪的是我們使用 .property 用於 fullname 函數的后面,除此之外沒看到其他特別的地方。我們再次來看看這個框架的代碼使用了何種魔法:

    Function.prototype.property = function() { var ret = Ember.computed(this); // ComputedProperty.prototype.property expands properties; no need for us to // do so here. return ret.property.apply(ret, arguments); }; 

Ember為全局Function對象擴充了一個property方法,這是一個很好的方式,在類的定義中運行一些邏輯。

Ember使用gettersetter方法​​對對象的數據進行操作。這簡化了動態屬性的實現,因為我們在操作實際變量之前添加了一個層,然而,如果我們能夠使用動態屬性與普通的JavaScript對象這將更有趣,例如:

    var User = { firstName: 'Tony', lastName: 'Stark', name: function() { // getter + setter } }; console.log(User.name); // Tony Stark User.name = 'John Doe'; console.log(User.firstName); // John console.log(User.lastName); // Doe 

name被當成普通的屬性使用,但在實現上卻是一個函數,其獲取或設置firstNamelastName

如下是JavaScript的一個內置的功能,它可以幫助我們實現這個想法,看看下面的代碼片段:

    var User = { firstName: 'Tony', lastName: 'Stark' }; Object.defineProperty(User, "name", { get: function() { return this.firstName + ' ' + this.lastName; }, set: function(value) { var parts = value.toString().split(/ /); this.firstName = parts[0]; this.lastName = parts[1] ? parts[1] : this.lastName; } }); 

Object.defineProperty方法接受一個對象、對象名稱以及需要定義的屬性的getter,setter方法,所有我們要做的就是寫這兩個方法的主體,就是這樣,我們將能夠運行上面的代碼中,我們會得到預期的結果:

    console.log(User.name); // Tony Stark User.name = 'John Doe'; console.log(User.firstName); // John console.log(User.lastName); // Doe 

Object.defineProperty正是我們所需要的,但我們不希望強制開發人員每次都這樣寫,我們可能需要提供一個 polyfill(譯者注:類似於插件、擴展),用於執行額外的邏輯,或者類似的東西,在理想的情況下,我們希望提供類似於Ember的一個接口,只有一個函數是類定義的一部分,在本節中,我們將編寫一個名為Computize的工具函數來處理我們的對象並以某種方式將name函數轉換為同名屬性。

    var Computize = function(obj) { return obj; } var User = Computize({ firstName: 'Tony', lastName: 'Stark', name: function() { ... } }); 

我們希望使用name方法作為setter和getter,這類似於Ember的動態屬性。

現在,讓我們添加我們自己的邏輯到Function對象的原型:

    Function.prototype.computed = function() { return { computed: true, func: this }; }; 

一旦我們添加了上面的代碼,我們就可以添加computed()方法到每個函數定義的結尾:

    name: function() { ... }.computed() 

其結果是,name屬性不再是一個函數了,而是一個對象,其包含值為true的屬性computed以及值為原函數的屬性func,真正的魔法發生在Computize幫手的實施,它會遍歷對象的所有屬性,並使用Object.defineProperty來重定義那些computed屬性為true的屬性:

    var Computize = function(obj) { for(var prop in obj) { if(typeof obj[prop] == 'object' && obj[prop].computed === true) { var func = obj[prop].func; delete obj[prop]; Object.defineProperty(obj, prop, { get: func, set: func }); } } return obj; } 

請注意,我們正在刪除原來的屬性名稱,因為在某些瀏覽器Object.defineProperty只適用於那些尚未定義的屬性。

下面是一個使用.Computize() 函數的User對象的最終版本。

    var User = Computize({ firstName: 'Tony', lastName: 'Stark', name: function() { if(arguments.length > 0) { var parts = arguments[0].toString().split(/ /); this.firstName = parts[0]; this.lastName = parts[1] ? parts[1] : this.lastName; } return this.firstName + ' ' + this.lastName; }.computed() }); 

返回全名的函數用於改變firstNamelastName,它檢測第一個參數是否存在,若存在,則分割並重新賦值到對應的屬性。

我們已經提到過想要的用法,但讓我們再來看一個例子:


    console.log(User.name); // Tony Stark User.name = 'John Doe'; console.log(User.firstName); // John console.log(User.lastName); // Doe console.log(User.name); // John Doe 

以下是最終結果:

Tony Stark
Krasimir
Tsonev
Krasimir Tsonev

譯者注:其實本節描述的本質,就是如何把對象的方法作為一個屬性來使用,類似高級語言中的get和set,那么其底層的關鍵實現是使用了 Object.defineProperty 來重定義屬性的getter和setter方法。

瘋狂的 React 模板

你可能聽說過Facebook的框架 React,它的設計思想是一切皆為組件,其中最有趣的是組件的定義,讓我們來看看下面的例子:

    <script type="text/jsx">; /** @jsx React.DOM */ var HelloMessage class="keyword operator">= React.createClass({ render: function() { return <div>Hello {this.props.name}</div>; } }); </script>; 

我們開始思考的第一件事是,這是一段JavaScript代碼,但它是無效的,它有一個 render 函數,但顯而易見會拋出一個語法錯誤,但是這里的訣竅是,這段代碼放在一個自定義type的 <script> 標簽里,因此瀏覽器不會處理它,這意味着它可以避免瀏覽器的語法檢查。React 有自己的解析器,會把這段代碼轉換為有效的JavaScript代碼,Facebook的工程師開發了類XML的語言JSX,JSX轉換器大小是390K,並包含大約12000行代碼,所以,這有點復雜。在本節中,我們將創建一個簡單的函數來實現它,但功能還是相當強悍的,該解析器會把HTML模板轉換為類似 React 的風格。

Facebook采取的方法是混合JavaScript代碼和HTML標記,現在假設我們有下面的模板:

    <script type="text/template" id="my-content">;
      <div class="content">;
        <h1>;<% title %>;</h1>;
      </div>;
    </script>;

然后添加一個類似這樣的組件:

    var Component = { title: 'Awesome template', render: '#my-content' } 

上面的代碼定義了模板的ID和引用的數據,接下來我們需要實現一個融合這兩種元素的引擎,讓我們命名為 Engine 並像這樣啟動它:

    var Engine = function(comp) { var parse = function(tplHTML) { // ... magic } var tpl = document.querySelector(comp.render); if(tpl) { var html = parse(tpl.innerHTML); return stringToDom(html); } } var el = Engine(Component); 

我們先得到的<script type="text/template" id="my-content">標記的內容,解析並生成HTML字符串,並把生成HTML轉換成一個有效的DOM元素,並返回最終結果,請注意,我們使用了本文第一節寫的 stringToDom 函數。

現在,讓我們來實現 parse 函數,我們的首要任務是從HTML中提取出表達式,即那些在 <% 和 %> 之間的語句,我們使用一個正則表達式來找到他們,並用一個簡單的 while 循環來遍歷所有的匹配:

    var parse = function(tplHTML) { var re = /<%([^%>]+)?%>/g; while(match = re.exec(tplHTML)) { console.log(match); } } 

上述代碼的運行結果是:

    [
        "<% title %>", "title", index: 55, input: "<div class="content"><h1><% title %></h1></div>" ] 

我們找到了一個表達式,其內容是 title,一個最簡單直觀的方法是使用JavaScript的 replace 函數替換<% title %> 為傳過來的 Comp 對象的數據,然而,這只適用於簡單的對象,如果我們有嵌套的對象,甚至,如果我們想使用函數,例如像這樣就行不通了:

    var Component = { data: { title: 'Awesome template', subtitle: function() { return 'Second title'; } }, render: '#my-content' } 

為了避免創建一個異常復雜的解析器甚至使用Javascript發明一種新的語言,一種最佳的辦法就是使用 new Function:

    var fn = new Function('arg', 'console.log(arg + 1);'); fn(2); // outputs 3 

我們可以動態構建一個函數體並延遲執行,但我們首先得知道表達式的位置以及其后面的內容,如果我們使用一個臨時數組和 cursor ,則 while 循環的代碼會是這樣:

    var parse = function(tplHTML) { var re = /<%([^%>]+)?%>/g; var code = [], cursor = 0; while(match = re.exec(tplHTML)) { code.push(tplHTML.slice(cursor, match.index)); code.push({code: match[1]}); // <-- expression cursor = match.index + match[0].length; } code.push(tplHTML.substr(cursor, tplHTML.length - cursor)); console.log(code); } 

在控制台的輸出說明我們做對了:

    [
      "<div class="content"><h1>", { code: "title" }, "</h1></div>" ] 

數組 code 最終應該轉變為一個字符串,這將是一個函數的主體部分,例如:

return "<div class=\"content\"><h1>" + title + "</h1></div>";

這個很容易實現,我們可以寫一個循環,遍歷上述代碼序列,檢測當前項目是字符串還是對象,然而,這僅適用於一部分場景,如果我們有下面的數據和模板:

    // component var Component = { title: 'Awesome template', colors: ['read', 'green', 'blue'], render: '#my-content' } // template <script type="text/template" id="my-content"> <div class="content"> <h1><% title %></h1> <% while(c = colors.shift()) { %> <p><% c %></p> <% } %> </div> </script> 

如果還是僅連接表達式並期望列出顏色那就錯了,考慮不再使用簡單的字符串追加,而是把它們收集在數組中,下面便是修改后的解析函數:

    var parse = function(tplHTML) { var re = /<%([^%>]+)?%>/g; var code = [], cursor = 0; while(match = re.exec(tplHTML)) { code.push(tplHTML.slice(cursor, match.index)); code.push({code: match[1]}); // <-- expression cursor = match.index + match[0].length; } code.push(tplHTML.substr(cursor, tplHTML.length - cursor)); var body = 'var r=[];\n'; while(line = code.shift()) { if(typeof line === 'string') { // escaping quotes line = line.replace(/"/g, '\\"'); // removing new lines line = line.replace(/[\r\t\n]/g, ''); body += 'r.push("' + line+ '");\n' } else { if(line.code.match(/(^( )?(if|for|else|switch|case|break|while|{|}))(.*)?/g)) { body += line.code + '\n'; } else { body += 'r.push(' + line.code + ');\n'; } } } body += 'return r.join("");'; console.log(body); } 

在函數的開始,我們把模板中所有語句存儲在 code 數組中,之后我們遍歷 code 數組,並嘗試把每一條語句存儲在數組 r 中,如果語句是字符串,則清除換行符和制表符,並用引號包裹,然后通過 push 方法添加到數組 r 中,如果語句是一個代碼片段,我們先檢查它是不是包含一個有效的JavaScript操作符,如果是無效的,那么我們不將它添加到數組,而只是純粹地添加到新的一行,最后我們來看下最后一句 console.log 的輸出結果:

    var r=[]; r.push("<div class=\"content\"><h1>"); r.push(title); r.push("</h1>"); while(c = colors.shift()) { r.push("<p>"); r.push(c); r.push("</p>"); } r.push("</div>"); return r.join(""); 

非常好,不是嗎?格式化成了正確的JavaScript代碼,這將在我們 Component 的上下文中執行並產生需要的HTML標記。

最后剩下的一件事是就是創建實際的函數並運行:

    body = 'with(component) {' + body + '}'; return new Function('component', body).apply(comp, [comp]); 

我們把生成的代碼包裹在 with 語句中,以便在 Component 的上下文中運行它,否則我們需要使用 this.title 和this.colors 來取代 title 和 colors

以下是在CodePen的演示結果:

Awesome template
read
green
blue

總結

在這些風靡的框架和庫的背后,隱藏着及其聰明的工程師,他們發現棘手的問題並使用巧妙的解決方案,是不平凡的,甚至有點不可思議,在這篇文章中,我們揭示了這些魔法,這對我們在 JavaScript 的世界學習和使用他們的代碼很有好處。

本文的代碼可以從GitHub上下載。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM