整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          手摸手,帶你用vue擼后臺 系列二(登錄權限篇)

          手摸手,帶你用vue擼后臺 系列二(登錄權限篇)



          整項目地址:vue-element-admin

          https://github.com/PanJiaChen/vue-element-admin

          前言

          拖更有點嚴重,過了半個月才寫了第二篇教程。無奈自己是一個業務猿,每天被我司的產品虐的死去活來,之前又病了一下休息了幾天,大家見諒。

          進入正題,做后臺項目區別于做其它的項目,權限驗證與安全性是非常重要的,可以說是一個后臺項目一開始就必須考慮和搭建的基礎核心功能。我們所要做到的是:不同的權限對應著不同的路由,同時側邊欄也需根據不同的權限,異步生成。這里先簡單說一下,我實現登錄和權限驗證的思路。

          • 登錄:當用戶填寫完賬號和密碼后向服務端驗證是否正確,驗證通過之后,服務端會返回一個token,拿到token之后(我會將這個token存貯到cookie中,保證刷新頁面后能記住用戶登錄狀態),前端會根據token再去拉取一個 user_info 的接口來獲取用戶的詳細信息(如用戶權限,用戶名等等信息)。
          • 權限驗證:通過token獲取用戶對應的 role,動態根據用戶的 role 算出其對應有權限的路由,通過 router.addRoutes 動態掛載這些路由。

          上述所有的數據和操作都是通過vuex全局管理控制的。(補充說明:刷新頁面后 vuex的內容也會丟失,所以需要重復上述的那些操作)接下來,我們一起手摸手一步一步實現這個系統。

          登錄篇

          首先我們不管什么權限,來實現最基礎的登錄功能。

          隨便找一個空白頁面擼上兩個input的框,一個是登錄賬號,一個是登錄密碼。再放置一個登錄按鈕。我們將登錄按鈕上綁上click事件,點擊登錄之后向服務端提交賬號和密碼進行驗證。 這就是一個最簡單的登錄頁面。如果你覺得還要寫的更加完美點,你可以在向服務端提交之前對賬號和密碼做一次簡單的校驗。詳細代碼



          click事件觸發登錄操作:

          this.$store.dispatch('LoginByUsername', this.loginForm).then(()=> {
            this.$router.push({ path: '/' }); //登錄成功之后重定向到首頁
          }).catch(err=> {
            this.$message.error(err); //登錄失敗提示錯誤
          });
          
          復制代碼

          action:

          LoginByUsername({ commit }, userInfo) {
            const username=userInfo.username.trim()
            return new Promise((resolve, reject)=> {
              loginByUsername(username, userInfo.password).then(response=> {
                const data=response.data
                Cookies.set('Token', response.data.token) //登錄成功后將token存儲在cookie之中
                commit('SET_TOKEN', data.token)
                resolve()
              }).catch(error=> {
                reject(error)
              });
            });
          }
          復制代碼

          登錄成功后,服務端會返回一個 token(該token的是一個能唯一標示用戶身份的一個key),之后我們將token存儲在本地cookie之中,這樣下次打開頁面或者刷新頁面的時候能記住用戶的登錄狀態,不用再去登錄頁面重新登錄了。

          ps:為了保證安全性,我司現在后臺所有token有效期(Expires/Max-Age)都是Session,就是當瀏覽器關閉了就丟失了。重新打開瀏覽器都需要重新登錄驗證,后端也會在每周固定一個時間點重新刷新token,讓后臺用戶全部重新登錄一次,確保后臺用戶不會因為電腦遺失或者其它原因被人隨意使用賬號。

          獲取用戶信息

          用戶登錄成功之后,我們會在全局鉤子router.beforeEach中攔截路由,判斷是否已獲得token,在獲得token之后我們就要去獲取用戶的基本信息了

          //router.beforeEach
          if (store.getters.roles.length===0) { // 判斷當前用戶是否已拉取完user_info信息
            store.dispatch('GetInfo').then(res=> { // 拉取user_info
              const roles=res.data.role;
              next();//resolve 鉤子
            })
          
          復制代碼

          就如前面所說的,我只在本地存儲了一個用戶的token,并沒有存儲別的用戶信息(如用戶權限,用戶名,用戶頭像等)。有些人會問為什么不把一些其它的用戶信息也存一下?主要出于如下的考慮:

          假設我把用戶權限和用戶名也存在了本地,但我這時候用另一臺電腦登錄修改了自己的用戶名,之后再用這臺存有之前用戶信息的電腦登錄,它默認會去讀取本地 cookie 中的名字,并不會去拉去新的用戶信息。

          所以現在的策略是:頁面會先從 cookie 中查看是否存有 token,沒有,就走一遍上一部分的流程重新登錄,如果有token,就會把這個 token 返給后端去拉取user_info,保證用戶信息是最新的。 當然如果是做了單點登錄得功能的話,用戶信息存儲在本地也是可以的。當你一臺電腦登錄時,另一臺會被提下線,所以總會重新登錄獲取最新的內容。

          而且從代碼層面我建議還是把 login和get_user_info兩件事分開比較好,在這個后端全面微服務的年代,后端同學也想寫優雅的代碼~


          權限篇

          先說一說我權限控制的主體思路,前端會有一份路由表,它表示了每一個路由可訪問的權限。當用戶登錄之后,通過 token 獲取用戶的 role ,動態根據用戶的 role 算出其對應有權限的路由,再通過router.addRoutes動態掛載路由。但這些控制都只是頁面級的,說白了前端再怎么做權限控制都不是絕對安全的,后端的權限驗證是逃不掉的。

          我司現在就是前端來控制頁面級的權限,不同權限的用戶顯示不同的側邊欄和限制其所能進入的頁面(也做了少許按鈕級別的權限控制),后端則會驗證每一個涉及請求的操作,驗證其是否有該操作的權限,每一個后臺的請求不管是 get 還是 post 都會讓前端在請求 header里面攜帶用戶的 token,后端會根據該 token 來驗證用戶是否有權限執行該操作。若沒有權限則拋出一個對應的狀態碼,前端檢測到該狀態碼,做出相對應的操作。

          權限 前端or后端 來控制?

          有很多人表示他們公司的路由表是于后端根據用戶的權限動態生成的,我司不采取這種方式的原因如下:

          • 項目不斷的迭代你會異常痛苦,前端新開發一個頁面還要讓后端配一下路由和權限,讓我們想了曾經前后端不分離,被后端支配的那段恐怖時間了。
          • 其次,就拿我司的業務來說,雖然后端的確也是有權限驗證的,但它的驗證其實是針對業務來劃分的,比如超級編輯可以發布文章,而實習編輯只能編輯文章不能發布,但對于前端來說不管是超級編輯還是實習編輯都是有權限進入文章編輯頁面的。所以前端和后端權限的劃分是不太一致。
          • 還有一點是就vue2.2.0之前異步掛載路由是很麻煩的一件事!不過好在官方也出了新的api,雖然本意是來解決ssr的痛點的。。。

          addRoutes

          在之前通過后端動態返回前端路由一直很難做的,因為vue-router必須是要vue在實例化之前就掛載上去的,不太方便動態改變。不過好在vue2.2.0以后新增了router.addRoutes

          Dynamically add more routes to the router. The argument must be an Array using the same route config format with the routes constructor option.

          有了這個我們就可相對方便的做權限控制了。(樓主之前在權限控制也走了不少歪路,可以在項目的commit記錄中看到,重構了很多次,最早沒用addRoute整個權限控制代碼里都是各種if/else的邏輯判斷,代碼相當的耦合和復雜)


          具體實現

          1. 創建vue實例的時候將vue-router掛載,但這個時候vue-router掛載一些登錄或者不用權限的公用的頁面。
          2. 當用戶登錄后,獲取用role,將role和路由表每個頁面的需要的權限作比較,生成最終用戶可訪問的路由表。
          3. 調用router.addRoutes(store.getters.addRouters)添加用戶可訪問的路由。
          4. 使用vuex管理路由表,根據vuex中可訪問的路由渲染側邊欄組件。

          router.js

          首先我們實現router.js路由表,這里就拿前端控制路由來舉例(后端存儲的也差不多,稍微改造一下就好了)

          // router.js
          import Vue from 'vue';
          import Router from 'vue-router';
          
          import Login from '../views/login/';
          const dashboard=resolve=> require(['../views/dashboard/index'], resolve);
          //使用了vue-routerd的[Lazy Loading Routes
          ](https://router.vuejs.org/en/advanced/lazy-loading.html)
          
          //所有權限通用路由表 
          //如首頁和登錄頁和一些不用權限的公用頁面
          export const constantRouterMap=[
            { path: '/login', component: Login },
            {
              path: '/',
              component: Layout,
              redirect: '/dashboard',
              name: '首頁',
              children: [{ path: 'dashboard', component: dashboard }]
            },
          ]
          
          //實例化vue的時候只掛載constantRouter
          export default new Router({
            routes: constantRouterMap
          });
          
          //異步掛載的路由
          //動態需要根據權限加載的路由表 
          export const asyncRouterMap=[
            {
              path: '/permission',
              component: Layout,
              name: '權限測試',
              meta: { role: ['admin','super_editor'] }, //頁面需要的權限
              children: [
              { 
                path: 'index',
                component: Permission,
                name: '權限測試頁',
                meta: { role: ['admin','super_editor'] }  //頁面需要的權限
              }]
            },
            { path: '*', redirect: '/404', hidden: true }
          ];
          
          復制代碼

          這里我們根據 vue-router官方推薦 的方法通過meta標簽來標示改頁面能訪問的權限有哪些。如meta: { role: ['admin','super_editor'] }表示該頁面只有admin和超級編輯才能有資格進入。

          注意事項:這里有一個需要非常注意的地方就是 404 頁面一定要最后加載,如果放在constantRouterMap一同聲明了404,后面的所以頁面都會被攔截到404,詳細的問題見addRoutes when you've got a wildcard route for 404s does not work

          main.js

          關鍵的main.js

          // main.js
          router.beforeEach((to, from, next)=> {
            if (store.getters.token) { // 判斷是否有token
              if (to.path==='/login') {
                next({ path: '/' });
              } else {
                if (store.getters.roles.length===0) { // 判斷當前用戶是否已拉取完user_info信息
                  store.dispatch('GetInfo').then(res=> { // 拉取info
                    const roles=res.data.role;
                    store.dispatch('GenerateRoutes', { roles }).then(()=> { // 生成可訪問的路由表
                      router.addRoutes(store.getters.addRouters) // 動態添加可訪問路由表
                      next({ ...to, replace: true }) // hack方法 確保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record
                    })
                  }).catch(err=> {
                    console.log(err);
                  });
                } else {
                  next() //當有用戶權限的時候,說明所有可訪問路由已生成 如訪問沒權限的全面會自動進入404頁面
                }
              }
            } else {
              if (whiteList.indexOf(to.path) !==-1) { // 在免登錄白名單,直接進入
                next();
              } else {
                next('/login'); // 否則全部重定向到登錄頁
              }
            }
          });
          
          復制代碼

          這里的router.beforeEach也結合了上一章講的一些登錄邏輯代碼。


          上面一張圖就是在使用addRoutes方法之前的權限判斷,非常的繁瑣,因為我是把所有的路由都掛在了上去,所有我要各種判斷當前的用戶是否有權限進入該頁面,各種if/else的嵌套,維護起來相當的困難。但現在有了addRoutes之后就非常的方便,我只掛載了用戶有權限進入的頁面,沒權限,路由自動幫我跳轉的404,省去了不少的判斷。


          這里還有一個小hack的地方,就是router.addRoutes之后的next()可能會失效,因為可能next()的時候路由并沒有完全add完成,好在查閱文檔發現

          next('/') or next({ path: '/' }): redirect to a different location. The current navigation will be aborted and a new one will be started.

          這樣我們就可以簡單的通過next(to)巧妙的避開之前的那個問題了。這行代碼重新進入router.beforeEach這個鉤子,這時候再通過next()來釋放鉤子,就能確保所有的路由都已經掛在完成了。

          store/permission.js

          就來就講一講 GenerateRoutes Action

          // store/permission.js
          import { asyncRouterMap, constantRouterMap } from 'src/router';
          
          function hasPermission(roles, route) {
            if (route.meta && route.meta.role) {
              return roles.some(role=> route.meta.role.indexOf(role) >=0)
            } else {
              return true
            }
          }
          
          const permission={
            state: {
              routers: constantRouterMap,
              addRouters: []
            },
            mutations: {
              SET_ROUTERS: (state, routers)=> {
                state.addRouters=routers;
                state.routers=constantRouterMap.concat(routers);
              }
            },
            actions: {
              GenerateRoutes({ commit }, data) {
                return new Promise(resolve=> {
                  const { roles }=data;
                  const accessedRouters=asyncRouterMap.filter(v=> {
                    if (roles.indexOf('admin') >=0) return true;
                    if (hasPermission(roles, v)) {
                      if (v.children && v.children.length > 0) {
                        v.children=v.children.filter(child=> {
                          if (hasPermission(roles, child)) {
                            return child
                          }
                          return false;
                        });
                        return v
                      } else {
                        return v
                      }
                    }
                    return false;
                  });
                  commit('SET_ROUTERS', accessedRouters);
                  resolve();
                })
              }
            }
          };
          
          export default permission;
          
          復制代碼

          這里的代碼說白了就是干了一件事,通過用戶的權限和之前在router.js里面asyncRouterMap的每一個頁面所需要的權限做匹配,最后返回一個該用戶能夠訪問路由有哪些。


          側邊欄

          最后一個涉及到權限的地方就是側邊欄,不過在前面的基礎上已經很方便就能實現動態顯示側邊欄了。這里側邊欄基于element-ui的NavMenu來實現的。 代碼有點多不貼詳細的代碼了,有興趣的可以直接去github上看地址,或者直接看關于側邊欄的文檔。

          說白了就是遍歷之前算出來的permission_routers,通過vuex拿到之后動態v-for渲染而已。不過這里因為有一些業務需求所以加了很多判斷 比如我們在定義路由的時候會加很多參數

          /**
          * hidden: true                   if `hidden:true` will not show in the sidebar(default is false)
          * redirect: noredirect           if `redirect:noredirect` will no redirct in the breadcrumb
          * name:'router-name'             the name is used by <keep-alive> (must set!!!)
          * meta : {
             role: ['admin','editor']     will control the page role (you can set multiple roles)
             title: 'title'               the name show in submenu and breadcrumb (recommend set)
             icon: 'svg-name'             the icon show in the sidebar,
             noCache: true                if fasle ,the page will no be cached(default is false)
           }
          **/
          復制代碼

          這里僅供參考,而且本項目為了支持無限嵌套路由,所有側邊欄這塊使用了遞歸組件。如需要請大家自行改造,來打造滿足自己業務需求的側邊欄。

          側邊欄高亮問題:很多人在群里問為什么自己的側邊欄不能跟著自己的路由高亮,其實很簡單,element-ui官方已經給了default-active所以我們只要

          :default-active="$route.path" 將default-active一直指向當前路由就可以了,就是這么簡單


          按鈕級別權限控制

          有很多人一直在問關于按鈕級別粒度的權限控制怎么做。我司現在是這樣的,真正需要按鈕級別控制的地方不是很多,現在是通過獲取到用戶的role之后,在前端用v-if手動判斷來區分不同權限對應的按鈕的。理由前面也說了,我司顆粒度的權限判斷是交給后端來做的,每個操作后端都會進行權限判斷。而且我覺得其實前端真正需要按鈕級別判斷的地方不是很多,如果一個頁面有很多種不同權限的按鈕,我覺得更多的應該是考慮產品層面是否設計合理。當然你強行說我想做按鈕級別的權限控制,你也可以參照路由層面的做法,搞一個操作權限表。。。但個人覺得有點多此一舉。或者將它封裝成一個指令都是可以的。


          axios攔截器

          這里再說一說 axios 吧。雖然在上一篇系列文章中簡單介紹過,不過這里還是要在嘮叨一下。如上文所說,我司服務端對每一個請求都會驗證權限,所以這里我們針對業務封裝了一下請求。首先我們通過request攔截器在每個請求頭里面塞入token,好讓后端對請求進行權限驗證。并創建一個respone攔截器,當服務端返回特殊的狀態碼,我們統一做處理,如沒權限或者token失效等操作。

          import axios from 'axios'
          import { Message } from 'element-ui'
          import store from '@/store'
          import { getToken } from '@/utils/auth'
          
          // 創建axios實例
          const service=axios.create({
            baseURL: process.env.BASE_API, // api的base_url
            timeout: 5000 // 請求超時時間
          })
          
          // request攔截器
          service.interceptors.request.use(config=> {
            // Do something before request is sent
            if (store.getters.token) {
              config.headers['X-Token']=getToken() // 讓每個請求攜帶token--['X-Token']為自定義key 請根據實際情況自行修改
            }
            return config
          }, error=> {
            // Do something with request error
            console.log(error) // for debug
            Promise.reject(error)
          })
          
          // respone攔截器
          service.interceptors.response.use(
            response=> response,
            /**
            * 下面的注釋為通過response自定義code來標示請求狀態,當code返回如下情況為權限有問題,登出并返回到登錄頁
            * 如通過xmlhttprequest 狀態碼標識 邏輯可寫在下面error中
            */
            //  const res=response.data;
            //     if (res.code !==20000) {
            //       Message({
            //         message: res.message,
            //         type: 'error',
            //         duration: 5 * 1000
            //       });
            //       // 50008:非法的token; 50012:其他客戶端登錄了;  50014:Token 過期了;
            //       if (res.code===50008 || res.code===50012 || res.code===50014) {
            //         MessageBox.confirm('你已被登出,可以取消繼續留在該頁面,或者重新登錄', '確定登出', {
            //           confirmButtonText: '重新登錄',
            //           cancelButtonText: '取消',
            //           type: 'warning'
            //         }).then(()=> {
            //           store.dispatch('FedLogOut').then(()=> {
            //             location.reload();// 為了重新實例化vue-router對象 避免bug
            //           });
            //         })
            //       }
            //       return Promise.reject('error');
            //     } else {
            //       return response.data;
            //     }
            error=> {
              console.log('err' + error)// for debug
              Message({
                message: error.message,
                type: 'error',
                duration: 5 * 1000
              })
              return Promise.reject(error)
            })
          
          export default service
          
          復制代碼

          兩步驗證




          文章一開始也說了,后臺的安全性是很重要的,簡簡單單的一個賬號+密碼的方式是很難保證安全性的。所以我司的后臺項目都是用了兩步驗證的方式,之前我們也嘗試過使用基于 google-authenticator 或者youbikey這樣的方式但難度和操作成本都比較大。后來還是準備借助騰訊爸爸,這年代誰不用微信。。。安全性騰訊爸爸也幫我做好了保障。 樓主建議兩步驗證要支持多個渠道不要只微信或者QQ,前段時間QQ第三方登錄就出了bug,官方兩三天才修好的,害我背了鍋/(ㄒoㄒ)/~~ 。

          這里的兩部驗證有點名不副實,其實就是賬號密碼驗證過之后還需要一個綁定的第三方平臺登錄驗證而已。 寫起來也很簡單,在原有登錄得邏輯上改造一下就好。

          this.$store.dispatch('LoginByEmail', this.loginForm).then(()=> {
            //this.$router.push({ path: '/' });
            //不重定向到首頁
            this.showDialog=true //彈出選擇第三方平臺的dialog
          }).catch(err=> {
            this.$message.error(err); //登錄失敗提示錯誤
          });
          復制代碼

          登錄成功之后不直接跳到首頁而是讓用戶兩步登錄,選擇登錄得平臺。 接下來就是所有第三方登錄一樣的地方通過 OAuth2.0 授權。這個各大平臺大同小異,大家自行查閱文檔,不展開了,就說一個微信授權比較坑的地方。注意你連參數的順序都不能換,不然會驗證不通過。具體代碼,同時我也封裝了openWindow方法大家自行看吧。 當第三方授權成功之后都會跳到一個你之前有一個傳入redirect——uri的頁面

          如微信還必須是你授權賬號的一級域名。所以你授權的域名是vue-element-admin.com,你就必須重定向到vue-element-admin.com/xxx/下面,所以你需要寫一個重定向的服務,如vue-element-admin.com/auth/redirect?a.com 跳到該頁面時會再次重定向給a.com。


          所以我們后臺也需要開一個authredirect頁面:代碼。他的作用是第三方登錄成功之后會默認跳到授權的頁面,授權的頁面會再次重定向回我們的后臺,由于是spa,改變路由的體驗不好,我們通過window.opener.location.href的方式改變hash,在login.js里面再監聽hash的變化。當hash變化時,獲取之前第三方登錄成功返回的code與第一步賬號密碼登錄之后返回的uid一同發送給服務端驗證是否正確,如果正確,這時候就是真正的登錄成功。

           created() {
               window.addEventListener('hashchange', this.afterQRScan);
             },
             destroyed() {
               window.removeEventListener('hashchange', this.afterQRScan);
             },
             afterQRScan() {
               const hash=window.location.hash.slice(1);
               const hashObj=getQueryObject(hash);
               const originUrl=window.location.origin;
               history.replaceState({}, '', originUrl);
               const codeMap={
                 wechat: 'code',
                 tencent: 'code'
               };
               const codeName=hashObj[codeMap[this.auth_type]];
               this.$store.dispatch('LoginByThirdparty', codeName).then(()=> {
                 this.$router.push({
                   path: '/'
                 });
               });
             }
          復制代碼

          到這里涉及登錄權限的東西也差不多講完了,這里樓主只是給了大家一個實現的思路(都是樓主不斷摸索的血淚史),每個公司實現的方案都有些出入,請謹慎選擇適合自己業務形態的解決方案。如果有什么想法或者建議歡迎去本項目下留言,一同討論。

          天給大家分享前端初學者必須要學習的標簽,這些標簽你都會了嗎?

          分享之前我還是要推薦下我自己的前端學習群:524262608,不管你是小白還是大牛,小編我都挺歡迎,不定期分享干貨,包括我自己整理的一份2017最新的前端資料,送給大家,歡迎初學和進階中的小伙伴

          DIV加css標簽

          頁頭:header

          登錄條:loginBar

          標志:logo

          側欄:sideBar

          廣告:banner

          導航:nav

          子導航:subNav

          菜單:menu

          子菜單:subMenu

          搜索:search

          滾動:scroll

          頁面主體:main

          內容:content

          標簽頁:tab

          文章列表:list

          提示信息:msg

          小技巧:tips

          欄目標題:title

          友情鏈接:friendLink

          頁腳:footer

          加入:joinus

          指南:guild

          服務:service

          熱點:hot

          新聞:news

          下載:download

          注冊:regsiter

          狀態:status

          按鈕:btn

          投票:vote

          合作伙伴:partner

          版權:copyRight

          CSSID的命名

          外套:wrap

          主導航:mainNav

          子導航:subnav

          頁腳:footer

          整個頁面:content

          頁眉:header

          頁腳:footer

          商標:label

          標題:title

          主導航:mainNav(globalNav)

          頂導航:topnav

          邊導航:sidebar

          左導航:leftsideBar

          右導航:rightsideBar

          旗志:logo

          標語:banner

          菜單內容1:menu1Content

          菜單容量:menuContainer

          子菜單:submenu

          邊導航圖標:sidebarIcon

          注釋:note

          面包屑:breadCrumb(即頁面所處位置導航提示)

          容器:container

          內容:content

          搜索:search

          登陸:login

          功能區:shop(如購物車,收銀臺)

          當前的current

          樣式文件命名

          主要的:master.css

          布局版面:layout.css

          專欄:columns.css

          文字:font.css

          打印樣式:print.css

          主題:themes.css

          標簽到這里就結束了,web前端學習的可以來我的群,群里每天都有對應資料學習:524262608,歡迎初學和進階中的小伙伴。

          web前端學習方法經驗可以關注我的微信公眾號:‘學習web前端’,每天一篇案例源碼或經驗分享,關注后回復‘給我資料’可以領取一套完整的學習視頻

          使用Electron開發客戶端程序已經有一段時間了,整體感覺還是非常不錯的,其中也遇到了一些坑點,本文是從【運行原理】到【實際應用】對Electron進行一次系統性的總結。【多圖,長文預警~】

          本文所有實例代碼均在我的github electron-react上,結合代碼閱讀文章效果更佳。另外electron-react還可作為使用Electron + React + Mobx + Webpack技術棧的腳手架工程。

          github:https://github.com/ConardLi/electron-react

          一、桌面應用程序



          桌面應用程序,又稱為 GUI 程序(Graphical User Interface),但是和 GUI 程序也有一些區別。桌面應用程序 將 GUI 程序從GUI 具體為“桌面”,使冷冰冰的像塊木頭一樣的電腦概念更具有 人性化,更生動和富有活力。

          我們電腦上使用的各種客戶端程序都屬于桌面應用程序,近年來WEB和移動端的興起讓桌面程序漸漸暗淡,但是在某些日常功能或者行業應用中桌面應用程序仍然是必不可少的。

          傳統的桌面應用開發方式,一般是下面兩種:

          1.1 原生開發

          直接將語言編譯成可執行文件,直接調用系統API,完成UI繪制等。這類開發技術,有著較高的運行效率,但一般來說,開發速度較慢,技術要求較高,例如:

          • 使用C++ / MFC開發Windows應用
          • 使用Objective-C開發MAC應用

          1.2 托管平臺

          一開始就有本地開發和UI開發。一次編譯后,得到中間文件,通過平臺或虛機完成二次加載編譯或解釋運行。運行效率低于原生編譯,但平臺優化后,其效率也是比較可觀的。就開發速度方面,比原生編譯技術要快一些。例如:

          • 使用C# / .NET Framework(只能開發Windows應用)
          • Java / Swing

          不過,上面兩種對前端開發人員太不友好了,基本是前端人員不會涉及的領域,但是在這個【大前端】的時代,前端開發者正在想方設法涉足各個領域,使用WEB技術開發客戶端的方式橫空出世。

          1.3 WEB開發

          使用WEB技術進行開發,利用瀏覽器引擎完成UI渲染,利用Node.js實現服務器端JS編程并可以調用系統API,可以把它想像成一個套了一個客戶端外殼的WEB應用。

          在界面上,WEB的強大生態為UI帶來了無限可能,并且開發、維護成本相對較低,有WEB開發經驗的前端開發者很容易上手進行開發。

          本文就來著重介紹使用WEB技術開發客戶端程序的技術之一【electron】

          二、Electron



          Electron是由Github開發,用HTML,CSS和JavaScript來構建跨平臺桌面應用程序的一個開源庫。 Electron通過將Chromium和Node.js合并到同一個運行時環境中,并將其打包為Mac,Windows和Linux系統下的應用來實現這一目的。

          2.1 使用Electron開發的理由:

          • 使用具有強大生態的Web技術進行開發,開發成本低,可擴展性強,更炫酷的UI
          • 跨平臺,一套代碼可打包為Windows、Linux、Mac三套軟件,且編譯快速
          • 可直接在現有Web應用上進行擴展,提供瀏覽器不具備的能力
          • 你是一個前端~

          當然,我們也要認清它的缺點:性能比原生桌面應用要低,最終打包后的應用比原生應用大很多。

          2.2 開發體驗

          兼容性

          雖然你還在用WEB技術進行開發,但是你不用再考慮兼容性問題了,你只需要關心你當前使用Electron的版本對應Chrome的版本,一般情況下它已經足夠新來讓你使用最新的API和語法了,你還可以手動升級Chrome版本。同樣的,你也不用考慮不同瀏覽器帶的樣式和代碼兼容問題。

          Node環境

          這可能是很多前端開發者曾經夢想過的功能,在WEB界面中使用Node.js提供的強大API,這意味著你在WEB頁面直接可以操作文件,調用系統API,甚至操作數據庫。當然,除了完整的Node API,你還可以使用額外的幾十萬個npm模塊。

          跨域

          你可以直接使用Node提供的request模塊進行網絡請求,這意味著你無需再被跨域所困擾。

          強大的擴展性

          借助node-ffi,為應用程序提供強大的擴展性(后面的章節會詳細介紹)。

          2.3 誰在用Electron


          現在市面上已經有非常多的應用在使用Electron進行開發了,包括我們熟悉的VS Code客戶端、GitHub客戶端、Atom客戶端等等。印象很深的,去年迅雷在發布迅雷X10.1時的文案:

          從迅雷X 10.1版本開始,我們采用Electron軟件框架完全重寫了迅雷主界面。使用新框架的迅雷X可以完美支持2K、4K等高清顯示屏,界面中的文字渲染也更加清晰銳利。從技術層面來說,新框架的界面繪制、事件處理等方面比老框架更加靈活高效,因此界面的流暢度也顯著優于老框架的迅雷。至于具體提升有多大?您一試便知。

          你可以打開VS Code,點擊【幫助】【切換開發人員工具】來調試VS Code客戶端的界面。


          三、Electron運行原理



          Electron 結合了 Chromium、Node.js 和用于調用操作系統本地功能的API。

          3.1 Chromium

          Chromium是Google為發展Chrome瀏覽器而啟動的開源項目,Chromium相當于Chrome的工程版或稱實驗版,新功能會率先在Chromium上實現,待驗證后才會應用在Chrome上,故Chrome的功能會相對落后但較穩定。

          Chromium為Electron提供強大的UI能力,可以在不考慮兼容性的情況下開發界面。

          3.2 Node.js

          Node.js是一個讓JavaScript運行在服務端的開發平臺,Node使用事件驅動,非阻塞I/O模型而得以輕量和高效。

          單單靠Chromium是不能具備直接操作原生GUI能力的,Electron內集成了Nodejs,這讓其在開發界面的同時也有了操作系統底層API的能力,Nodejs 中常用的 Path、fs、Crypto 等模塊在 Electron 可以直接使用。

          3.3 系統API

          為了提供原生系統的GUI支持,Electron內置了原生應用程序接口,對調用一些系統功能,如調用系統通知、打開系統文件夾提供支持。

          在開發模式上,Electron在調用系統API和繪制界面上是分離開發的,下面我們來看看Electron關于進程如何劃分。

          3.4 主進程

          Electron區分了兩種進程:主進程和渲染進程,兩者各自負責自己的職能。



          Electron 運行package.json的 main 腳本的進程被稱為主進程。一個 Electron 應用總是有且只有一個主進程。

          職責:

          • 創建渲染進程(可多個)
          • 控制了應用生命周期(啟動、退出APP以及對APP做一些事件監聽)
          • 調用系統底層功能、調用原生資源

          可調用的API:

          • Node.js API
          • Electron提供的主進程API(包括一些系統功能和Electron附加功能)

          3.5 渲染進程

          由于 Electron 使用了 Chromium 來展示 web 頁面,所以 Chromium 的多進程架構也被使用到。 每個Electron 中的 web頁面運行在它自己的渲染進程中。

          主進程使用 BrowserWindow 實例創建頁面。 每個 BrowserWindow 實例都在自己的渲染進程里運行頁面。 當一個 BrowserWindow 實例被銷毀后,相應的渲染進程也會被終止。

          你可以把渲染進程想像成一個瀏覽器窗口,它能存在多個并且相互獨立,不過和瀏覽器不同的是,它能調用Node API。

          職責:

          • 用HTML和CSS渲染界面
          • 用JavaScript做一些界面交互

          可調用的API:

          • DOM API
          • Node.js API
          • Electron提供的渲染進程API

          四、Electron基礎

          4.1 Electron API

          在上面的章節我們提到,渲染進和主進程分別可調用的Electron API。所有Electron的API都被指派給一種進程類型。 許多API只能被用于主進程中,有些API又只能被用于渲染進程,又有一些主進程和渲染進程中都可以使用。

          你可以通過如下方式獲取Electron API

          const { BrowserWindow, ... }=require('electron')
          復制代碼

          下面是一些常用的Electron API:


          在后面的章節我們會選擇其中常用的模塊進行詳細介紹。

          4.2 使用 Node.js 的 API



          你可以同時在Electron的主進程和渲染進程使用Node.js API,)所有在Node.js可以使用的API,在Electron中同樣可以使用。

          import {shell} from 'electron';
          import os from 'os';
          
          document.getElementById('btn').addEventListener('click', ()=> { 
            shell.showItemInFolder(os.homedir());
          })
          復制代碼

          有一個非常重要的提示: 原生Node.js模塊 (即指,需要編譯源碼過后才能被使用的模塊) 需要在編譯后才能和Electron一起使用。

          4.3 進程通信

          主進程和渲染進程雖然擁有不同的職責,然是他們也需要相互協作,互相通訊。

          例如:在web頁面管理原生GUI資源是很危險的,會很容易泄露資源。所以在web頁面,不允許直接調用原生GUI相關的API。渲染進程如果想要進行原生的GUI操作,就必須和主進程通訊,請求主進程來完成這些操作。



          4.4 渲染進程向主進程通信

          ipcRenderer 是一個 EventEmitter 的實例。 你可以使用它提供的一些方法,從渲染進程發送同步或異步的消息到主進程。 也可以接收主進程回復的消息。

          在渲染進程引入ipcRenderer:

          import { ipcRenderer } from 'electron';
          復制代碼

          異步發送:

          通過 channel 發送同步消息到主進程,可以攜帶任意參數。

          在內部,參數會被序列化為 JSON,因此參數對象上的函數和原型鏈不會被發送。

          ipcRenderer.send('async-render', '我是來自渲染進程的異步消息');
          復制代碼

          同步發送:

           const msg=ipcRenderer.sendSync('sync-render', '我是來自渲染進程的同步消息');
          復制代碼

          注意: 發送同步消息將會阻塞整個渲染進程,直到收到主進程的響應。

          主進程監聽消息:

          ipcMain模塊是EventEmitter類的一個實例。 當在主進程中使用時,它處理從渲染器進程(網頁)發送出來的異步和同步信息。 從渲染器進程發送的消息將被發送到該模塊。

          ipcMain.on:監聽 channel,當接收到新的消息時 listener 會以 listener(event, args...) 的形式被調用。

            ipcMain.on('sync-render', (event, data)=> {
              console.log(data);
            });
          復制代碼

          4.5 主進程向渲染進程通信

          在主進程中可以通過BrowserWindow的webContents向渲染進程發送消息,所以,在發送消息前你必須先找到對應渲染進程的BrowserWindow對象。:

          const mainWindow=BrowserWindow.fromId(global.mainId);
           mainWindow.webContents.send('main-msg', `ConardLi]`)
          復制代碼

          根據消息來源發送:

          在ipcMain接受消息的回調函數中,通過第一個參數event的屬性sender可以拿到消息來源渲染進程的webContents對象,我們可以直接用此對象回應消息。

            ipcMain.on('sync-render', (event, data)=> {
              console.log(data);
              event.sender.send('main-msg', '主進程收到了渲染進程的【異步】消息!')
            });
          復制代碼

          渲染進程監聽:

          ipcRenderer.on:監聽 channel, 當新消息到達,將通過listener(event, args...)調用 listener。

          ipcRenderer.on('main-msg', (event, msg)=> {
              console.log(msg);
          })
          復制代碼

          4.6 通信原理

          ipcMain 和 ipcRenderer 都是 EventEmitter 類的一個實例。EventEmitter 類是 NodeJS 事件的基礎,它由 NodeJS 中的 events 模塊導出。

          EventEmitter 的核心就是事件觸發與事件監聽器功能的封裝。它實現了事件模型需要的接口, 包括 addListener,removeListener, emit 及其它工具方法. 同原生 JavaScript 事件類似, 采用了發布/訂閱(觀察者)的方式, 使用內部 _events 列表來記錄注冊的事件處理器。

          我們通過 ipcMain和ipcRenderer 的 on、send 進行監聽和發送消息都是 EventEmitter 定義的相關接口。

          4.7 remote

          remote 模塊為渲染進程(web頁面)和主進程通信(IPC)提供了一種簡單方法。 使用 remote 模塊, 你可以調用 main 進程對象的方法, 而不必顯式發送進程間消息, 類似于 Java 的 RMI 。

          import { remote } from 'electron';
          
          remote.dialog.showErrorBox('主進程才有的dialog模塊', '我是使用remote調用的')
          復制代碼



          但實際上,我們在調用遠程對象的方法、函數或者通過遠程構造函數創建一個新的對象,實際上都是在發送一個同步的進程間消息。

          在上面通過 remote 模塊調用 dialog 的例子里。我們在渲染進程中創建的 dialog 對象其實并不在我們的渲染進程中,它只是讓主進程創建了一個 dialog 對象,并返回了這個相對應的遠程對象給了渲染進程。

          4.8 渲染進程間通信

          Electron并沒有提供渲染進程之間相互通信的方式,我們可以在主進程中建立一個消息中轉站。

          渲染進程之間通信首先發送消息到主進程,主進程的中轉站接收到消息后根據條件進行分發。

          4.9 渲染進程數據共享

          在兩個渲染進程間共享數據最簡單的方法是使用瀏覽器中已經實現的HTML5 API。 其中比較好的方案是用Storage API, localStorage,sessionStorage 或者 IndexedDB。

          就像在瀏覽器中使用一樣,這種存儲相當于在應用程序中永久存儲了一部分數據。有時你并不需要這樣的存儲,只需要在當前應用程序的生命周期內進行一些數據的共享。這時你可以用 Electron 內的 IPC 機制實現。

          將數據存在主進程的某個全局變量中,然后在多個渲染進程中使用 remote 模塊來訪問它。



          在主進程中初始化全局變量:

          global.mainId=...;
          global.device={...};
          global.__dirname=__dirname;
          global.myField={ name: 'ConardLi' };
          復制代碼

          在渲染進程中讀取:

          import { ipcRenderer, remote } from 'electron';
          
          const { getGlobal }=remote;
          
          const mainId=getGlobal('mainId')
          const dirname=getGlobal('__dirname')
          const deviecMac=getGlobal('device').mac;
          復制代碼

          在渲染進程中改變:

          getGlobal('myField').name='code秘密花園';
          復制代碼

          多個渲染進程共享同一個主進程的全局變量,這樣即可達到渲染進程數據共享和傳遞的效果。

          五、窗口

          5.1 BrowserWindow

          主進程模塊BrowserWindow用于創建和控制瀏覽器窗口。

            mainWindow=new BrowserWindow({
              width: 1000,
              height: 800,
              // ...
            });
            mainWindow.loadURL('http://www.conardli.top/');
          復制代碼

          你可以在這里查看它所有的構造參數。



          5.2 無框窗口

          無框窗口是沒有鑲邊的窗口,窗口的部分(如工具欄)不屬于網頁的一部分。

          在BrowserWindow的構造參數中,將frame設置為false可以指定窗口為無邊框窗口,將工具欄隱藏后,就會產生兩個問題:

          • 1.窗口控制按鈕(最小化、全屏、關閉按鈕)會被隱藏
          • 2.無法拖拽移動窗口

          可以通過指定titleBarStyle選項來再將工具欄按鈕顯示出來,將其設置為hidden表示返回一個隱藏標題欄的全尺寸內容窗口,在左上角仍然有標準的窗口控制按鈕。

          new BrowserWindow({
              width: 200,
              height: 200,
              titleBarStyle: 'hidden',
              frame: false
            });
          復制代碼

          5.3 窗口拖拽

          默認情況下, 無邊框窗口是不可拖拽的。我們可以在界面中通過CSS屬性-webkit-app-region: drag手動制定拖拽區域。

          在無框窗口中, 拖動行為可能與選擇文本沖突,可以通過設定-webkit-user-select: none;禁用文本選擇:

          .header {
            -webkit-user-select: none;
            -webkit-app-region: drag;
          }
          復制代碼

          相反的,在可拖拽區域內部設置 -webkit-app-region: no-drag則可以指定特定不可拖拽區域。

          5.4 透明窗口

          通過將transparent選項設置為true, 還可以使無框窗口透明:

          new BrowserWindow({
              transparent: true,
              frame: false
            });
          復制代碼

          5.5 Webview

          使用 webview 標簽在Electron 應用中嵌入 "外來" 內容。外來內容包含在 webview 容器中。 應用中的嵌入頁面可以控制外來內容的布局和重繪。

          與 iframe 不同, webview 在與應用程序不同的進程中運行。它與您的網頁沒有相同的權限, 應用程序和嵌入內容之間的所有交互都將是異步的。

          六、對話框

          dialog 模塊提供了api來展示原生的系統對話框,例如打開文件框,alert框,所以web應用可以給用戶帶來跟系統應用相同的體驗。

          注意:dialog是主進程模塊,想要在渲染進程調用可以使用remote



          6.1 錯誤提示

          dialog.showErrorBox用于顯示一個顯示錯誤消息的模態對話框。

           remote.dialog.showErrorBox('錯誤', '這是一個錯誤彈框!')
          復制代碼

          6.2 對話框

          dialog.showErrorBox用于調用系統對話框,可以為指定幾種不同的類型: "none", "info", "error", "question" 或者 "warning"。

          在 Windows 上, "question" 與"info"顯示相同的圖標, 除非你使用了 "icon" 選項設置圖標。 在 macOS 上, "warning" 和 "error" 顯示相同的警告圖標

          remote.dialog.showMessageBox({
            type: 'info',
            title: '提示信息',
            message: '這是一個對話彈框!',
            buttons: ['確定', '取消']
          }, (index)=> {
            this.setState({ dialogMessage: `【你點擊了${index ? '取消' : '確定'}!!】` })
          })
          復制代碼

          6.3 文件框

          dialog.showOpenDialog用于打開或選擇系統目錄。

          remote.dialog.showOpenDialog({
            properties: ['openDirectory', 'openFile']
          }, (data)=> {
            this.setState({ filePath: `【選擇路徑:${data[0]}】 ` })
          })
          復制代碼

          6.4 信息框

          這里推薦直接使用HTML5 API,它只能在渲染器進程中使用。

          let options={
            title: '信息框標題',
            body: '我是一條信息~~~',
          }
          let myNotification=new window.Notification(options.title, options)
          myNotification.onclick=()=> {
            this.setState({ message: '【你點擊了信息框!!】' })
          }
          復制代碼

          七、系統

          7.1 獲取系統信息



          通過remote獲取到主進程的process對象,可以獲取到當前應用的各個版本信息:

          • process.versions.electron:electron版本信息
          • process.versions.chrome:chrome版本信息
          • process.versions.node:node版本信息
          • process.versions.v8:v8版本信息

          獲取當前應用根目錄:

          remote.app.getAppPath()
          復制代碼

          使用node的os模塊獲取當前系統根目錄:

          os.homedir();
          復制代碼

          7.2 復制粘貼



          Electron提供的clipboard在渲染進程和主進程都可使用,用于在系統剪貼板上執行復制和粘貼操作。

          以純文本的形式寫入剪貼板:

          clipboard.writeText(text[, type])
          復制代碼

          以純文本的形式獲取剪貼板的內容:

          clipboard.readText([type])
          復制代碼

          7.3 截圖

          desktopCapturer用于從桌面捕獲音頻和視頻的媒體源的信息。它只能在渲染進程中被調用。



          下面的代碼是一個獲取屏幕截圖并保存的實例:

            getImg=()=> {
              this.setState({ imgMsg: '正在截取屏幕...' })
              const thumbSize=this.determineScreenShotSize()
              let options={ types: ['screen'], thumbnailSize: thumbSize }
              desktopCapturer.getSources(options, (error, sources)=> {
                if (error) return console.log(error)
                sources.forEach((source)=> {
                  if (source.name==='Entire screen' || source.name==='Screen 1') {
                    const screenshotPath=path.join(os.tmpdir(), 'screenshot.png')
                    fs.writeFile(screenshotPath, source.thumbnail.toPNG(), (error)=> {
                      if (error) return console.log(error)
                      shell.openExternal(`file://${screenshotPath}`)
                      this.setState({ imgMsg: `截圖保存到: ${screenshotPath}` })
                    })
                  }
                })
              })
            }
          
            determineScreenShotSize=()=> {
              const screenSize=screen.getPrimaryDisplay().workAreaSize
              const maxDimension=Math.max(screenSize.width, screenSize.height)
              return {
                width: maxDimension * window.devicePixelRatio,
                height: maxDimension * window.devicePixelRatio
              }
            }
          
          復制代碼

          八、菜單

          應用程序的菜單可以幫助我們快捷的到達某一功能,而不借助客戶端的界面資源,一般菜單分為兩種:

          • 應用程序菜單:位于應用程序頂部,在全局范圍內都能使用
          • 上下文菜單:可自定義任意頁面顯示,自定義調用,如右鍵菜單

          Electron為我們提供了Menu模塊用于創建本機應用程序菜單和上下文菜單,它是一個主進程模塊。

          你可以通過Menu的靜態方法buildFromTemplate(template),使用自定義菜單模版來構造一個菜單對象。

          template是一個MenuItem的數組,我們來看看MenuItem的幾個重要參數:

          • label:菜單顯示的文字
          • click:點擊菜單后的事件處理函數
          • role:系統預定義的菜單,例如copy(復制)、paste(粘貼)、minimize(最小化)...
          • enabled:指示是否啟用該項目,此屬性可以動態更改
          • submenu:子菜單,也是一個MenuItem的數組

          推薦:最好指定role與標準角色相匹配的任何菜單項,而不是嘗試手動實現click函數中的行為。內置role行為將提供最佳的本地體驗。

          下面的實例是一個簡單的額菜單template。

          const template=[
            {
              label: '文件',
              submenu: [
                {
                  label: '新建文件',
                  click: function () {
                    dialog.showMessageBox({
                      type: 'info',
                      message: '嘿!',
                      detail: '你點擊了新建文件!',
                    })
                  }
                }
              ]
            },
            {
              label: '編輯',
              submenu: [{
                label: '剪切',
                role: 'cut'
              }, {
                label: '復制',
                role: 'copy'
              }, {
                label: '粘貼',
                role: 'paste'
              }]
            },
            {
              label: '最小化',
              role: 'minimize'
            }
          ]
          復制代碼

          8.1 應用程序菜單

          使用Menu的靜態方法setApplicationMenu,可創建一個應用程序菜單,在 Windows 和 Linux 上,menu將被設置為每個窗口的頂層菜單。

          注意:必須在模塊ready事件后調用此 API app。

          我們可以根據應用程序不同的的生命周期,不同的系統對菜單做不同的處理。


          app.on('ready', function () {
            const menu=Menu.buildFromTemplate(template)
            Menu.setApplicationMenu(menu)
          })
          
          app.on('browser-window-created', function () {
            let reopenMenuItem=findReopenMenuItem()
            if (reopenMenuItem) reopenMenuItem.enabled=false
          })
          
          app.on('window-all-closed', function () {
            let reopenMenuItem=findReopenMenuItem()
            if (reopenMenuItem) reopenMenuItem.enabled=true
          })
          
          if (process.platform==='win32') {
            const helpMenu=template[template.length - 1].submenu
            addUpdateMenuItems(helpMenu, 0)
          }
          復制代碼

          8.2 上下文菜單

          使用Menu的實例方法menu.popup可自定義彈出上下文菜單。



              let m=Menu.buildFromTemplate(template)
              document.getElementById('menuDemoContainer').addEventListener('contextmenu', (e)=> {
                e.preventDefault()
                m.popup({ window: remote.getCurrentWindow() })
              })
          復制代碼

          8.3 快捷鍵

          在菜單選項中,我們可以指定一個accelerator屬性來指定操作的快捷鍵:

            {
              label: '最小化',
              accelerator: 'CmdOrCtrl+M',
              role: 'minimize'
            }
          復制代碼

          另外,我們還可以使用globalShortcut來注冊全局快捷鍵。

              globalShortcut.register('CommandOrControl+N', ()=> {
                dialog.showMessageBox({
                  type: 'info',
                  message: '嘿!',
                  detail: '你觸發了手動注冊的快捷鍵.',
                })
              })
          復制代碼

          CommandOrControl代表在macOS上為Command鍵,以及在Linux和Windows上為Control鍵。

          九、打印

          很多情況下程序中使用的打印都是用戶無感知的。并且想要靈活的控制打印內容,往往需要借助打印機給我們提供的api再進行開發,這種開發方式非常繁瑣,并且開發難度較大。第一次在業務中用到Electron其實就是用到它的打印功能,這里就多介紹一些。

          Electron提供的打印api可以非常靈活的控制打印設置的顯示,并且可以通過html來書寫打印內容。Electron提供了兩種方式進行打印,一種是直接調用打印機打印,一種是打印到pdf。

          并且有兩種對象可以調用打印:

          • 通過window的webcontent對象,使用此種方式需要單獨開出一個打印的窗口,可以將該窗口隱藏,但是通信調用相對復雜。
          • 使用頁面的webview元素調用打印,可以將webview隱藏在調用的頁面中,通信方式比較簡單。

          上面兩種方式同時擁有print和printToPdf方法。



          9.1 調用系統打印

          contents.print([options], [callback]);
          復制代碼

          打印配置(options)中只有簡單的三個配置:

          • silent:打印時是否不展示打印配置(是否靜默打印)
          • printBackground:是否打印背景
          • deviceName:打印機設備名稱

          首先要將我們使用的打印機名稱配置好,并且要在調用打印前首先要判斷打印機是否可用。

          使用webContents的getPrinters方法可獲取當前設備已經配置的打印機列表,注意配置過不是可用,只是在此設備上安裝過驅動。

          通過getPrinters獲取到的打印機對象:electronjs.org/docs/api/st…

          我們這里只管關心兩個,name和status,status為0時表示打印機可用。

          print的第二個參數callback是用于判斷打印任務是否發出的回調,而不是打印任務完成后的回調。所以一般打印任務發出,回調函數即會調用并返回參數true。這個回調并不能判斷打印是否真的成功了。

              if (this.state.curretnPrinter) {
                mainWindow.webContents.print({
                  silent: silent, printBackground: true, deviceName: this.state.curretnPrinter
                }, ()=> { })
              } else {
                remote.dialog.showErrorBox('錯誤', '請先選擇一個打印機!')
              }
          復制代碼

          9.2 打印到PDF

          printToPdf的用法基本和print相同,但是print的配置項非常少,而printToPdf則擴展了很多屬性。這里翻了一下源碼發現還有很多沒有被貼進文檔的,大概有三十幾個,包括可以對打印的margin,打印頁眉頁腳等進行配置。

          contents.printToPDF(options, callback)
          復制代碼

          callback函數在打印失敗或打印成功后調用,可獲取打印失敗信息或包含PDF數據的緩沖區。

              const pdfPath=path.join(os.tmpdir(), 'webviewPrint.pdf');
              const webview=document.getElementById('printWebview');
              const renderHtml='我是被臨時插入webview的內容...';
              webview.executeJavaScript('document.documentElement.innerHTML=`' + renderHtml + '`;');
              webview.printToPDF({}, (err, data)=> {
                console.log(err, data);
                fs.writeFile(pdfPath, data, (error)=> {
                  if (error) throw error
                  shell.openExternal(`file://${pdfPath}`)
                  this.setState({ webviewPdfPath: pdfPath })
                });
              });
          復制代碼

          這個例子中的打印是使用webview完成的,通過調用executeJavaScript方法可動態向webview插入打印內容。

          9.3 兩種打印方案的選擇

          上面提到,使用webview和webcontent都可以調用打印功能,使用webcontent打印,首先要有一個打印窗口,這個窗口不能隨時打印隨時創建,比較耗費性能。可以將它在程序運行時啟動好,并做好事件監聽。

          此過程需和調用打印的進行做好通信,大致過程如下:



          可見通信非常繁瑣,使用webview進行打印可實現同樣的效果但是通信方式會變得簡單,因為渲染進程和webview通信不需要經過主進程,通過如下方式即可:

            const webview=document.querySelector('webview')
            webview.addEventListener('ipc-message', (event)=> {
              console.log(event.channel)
            })
            webview.send('ping');
          
            const {ipcRenderer}=require('electron')
            ipcRenderer.on('ping', ()=> {
              ipcRenderer.sendToHost('pong')
            })
          復制代碼

          之前專門為ELectron打印寫過一個DEMO:electron-print-demo有興趣可以clone下來看一下。

          9.4 打印功能封裝

          下面是幾個針對常用打印功能的工具函數封裝。

          /**
           * 獲取系統打印機列表
           */
          export function getPrinters() {
            let printers=[];
            try {
              const contents=remote.getCurrentWindow().webContents;
              printers=contents.getPrinters();
            } catch (e) {
              console.error('getPrintersError', e);
            }
            return printers;
          }
          /**
           * 獲取系統默認打印機
           */
          export function getDefaultPrinter() {
            return getPrinters().find(element=> element.isDefault);
          }
          /**
           * 檢測是否安裝了某個打印驅動
           */
          export function checkDriver(driverMame) {
            return getPrinters().find(element=> (element.options["printer-make-and-model"] || '').includes(driverMame));
          }
          /**
           * 根據打印機名稱獲取打印機對象
           */
          export function getPrinterByName(name) {
            return getPrinters().find(element=> element.name===name);
          }
          
          復制代碼

          十、程序保護



          10.1 崩潰

          崩潰監控是每個客戶端程序必備的保護功能,當程序崩潰時我們一般期望做到兩件事:

          • 1.上傳崩潰日志,及時報警
          • 2.監控程序崩潰,提示用戶重啟程序

          electron為我們提供給了crashReporter來幫助我們記錄崩潰日志,我們可以通過crashReporter.start來創建一個崩潰報告器:

          const { crashReporter }=require('electron')
          crashReporter.start({
            productName: 'YourName',
            companyName: 'YourCompany',
            submitURL: 'https://your-domain.com/url-to-submit',
            uploadToServer: true
          })
          復制代碼

          當程序發生崩潰時,崩潰報日志將被儲存在臨時文件夾中名為YourName Crashes的文件文件夾中。submitURL用于指定你的崩潰日志上傳服務器。 在啟動崩潰報告器之前,您可以通過調用app.setPath('temp', 'my/custom/temp')API來自定義這些臨時文件的保存路徑。你還可以通過crashReporter.getLastCrashReport()來獲取上次崩潰報告的日期和ID。

          我們可以通過webContents的crashed來監聽渲染進程的崩潰,另外經測試有些主進程的崩潰也會觸發該事件。所以我們可以根據主window是否被銷毀來判斷進行不同的重啟邏輯,下面是整個崩潰監控的邏輯:

          import { BrowserWindow, crashReporter, dialog } from 'electron';
          // 開啟進程崩潰記錄
          crashReporter.start({
            productName: 'electron-react',
            companyName: 'ConardLi',
            submitURL: 'http://xxx.com',  // 上傳崩潰日志的接口
            uploadToServer: false
          });
          function reloadWindow(mainWin) {
            if (mainWin.isDestroyed()) {
              app.relaunch();
              app.exit(0);
            } else {
              // 銷毀其他窗口
              BrowserWindow.getAllWindows().forEach((w)=> {
                if (w.id !==mainWin.id) w.destroy();
              });
              const options={
                type: 'info',
                title: '渲染器進程崩潰',
                message: '這個進程已經崩潰.',
                buttons: ['重載', '關閉']
              }
              dialog.showMessageBox(options, (index)=> {
                if (index===0) mainWin.reload();
                else mainWin.close();
              })
            }
          }
          export default function () {
            const mainWindow=BrowserWindow.fromId(global.mainId);
            mainWindow.webContents.on('crashed', ()=> {
              const errorMessage=crashReporter.getLastCrashReport();
              console.error('程序崩潰了!', errorMessage); // 可單獨上傳日志
              reloadWindow(mainWindow);
            });
          }
          復制代碼

          10.2 最小化到托盤

          有的時候我們并不想讓用戶通過點關閉按鈕的時候就關閉程序,而是把程序最小化到托盤,在托盤上做真正的退出操作。

          首先要監聽窗口的關閉事件,阻止用戶關閉操作的默認行為,將窗口隱藏。

          function checkQuit(mainWindow, event) {
            const options={
              type: 'info',
              title: '關閉確認',
              message: '確認要最小化程序到托盤嗎?',
              buttons: ['確認', '關閉程序']
            };
            dialog.showMessageBox(options, index=> {
              if (index===0) {
                event.preventDefault();
                mainWindow.hide();
              } else {
                mainWindow=null;
                app.exit(0);
              }
            });
          }
          function handleQuit() {
            const mainWindow=BrowserWindow.fromId(global.mainId);
            mainWindow.on('close', event=> {
              event.preventDefault();
              checkQuit(mainWindow, event);
            });
          }
          復制代碼

          這時程序就再也找不到了,任務托盤中也沒有我們的程序,所以我們要先創建好任務托盤,并做好事件監聽。

          windows平臺使用ico文件可以達到更好的效果

          export default function createTray() {
            const mainWindow=BrowserWindow.fromId(global.mainId);
            const iconName=process.platform==='win32' ? 'icon.ico' : 'icon.png'
            tray=new Tray(path.join(global.__dirname, iconName));
            const contextMenu=Menu.buildFromTemplate([
              {
                label: '顯示主界面', click: ()=> {
                  mainWindow.show();
                  mainWindow.setSkipTaskbar(false);
                }
              },
              {
                label: '退出', click: ()=> {
                  mainWindow.destroy();
                  app.quit();
                }
              },
            ])
            tray.setToolTip('electron-react');
            tray.setContextMenu(contextMenu);
          }
          復制代碼

          十一、擴展能力



          在很多情況下,你的應用程序要和外部設備進行交互,一般情況下廠商會為你提供硬件設備的開發包,這些開發包基本上都是通過C++ 編寫,在使用electron開發的情況下,我們并不具備直接調用C++代碼的能力,我們可以利用node-ffi來實現這一功能。

          node-ffi提供了一組強大的工具,用于在Node.js環境中使用純JavaScript調用動態鏈接庫接口。它可以用來為庫構建接口綁定,而不需要使用任何C++代碼。

          注意node-ffi并不能直接調用C++代碼,你需要將C++代碼編譯為動態鏈接庫:在 Windows下是 Dll ,在 Mac OS下是 dylib ,Linux 是 so 。

          node-ffi 加載 Library是有限制的,只能處理 C風格的 Library。

          下面是一個簡單的實例:

          const ffi=require('ffi');
          const ref=require('ref');
          const SHORT_CODE=ref.refType('short');
          
          
          const DLL=new ffi.Library('test.dll', {
              Test_CPP_Method: ['int', ['string',SHORT_CODE]], 
            })
          
          testCppMethod(str: String, num: number): void {
            try {
              const result: any=DLL.Test_CPP_Method(str, num);
              return result;
            } catch (error) {
              console.log('調用失敗~',error);
            }
          }
          
          this.testCppMethod('ConardLi',123);
          復制代碼

          上面的代碼中,我們用ffi包裝C++接口生成的動態鏈接庫test.dll,并使用ref進行一些類型映射。

          使用JavaScript調用這些映射方法時,推薦使用TypeScript來約定參數類型,因為弱類型的JavaScript在調用強類型語言的接口時可能會帶來意想不到的風險。

          借助這一能力,前端開發工程師也可以在IOT領域一展身手了~

          十二、環境選擇

          一般情況下,我們的應用程序可能運行在多套環境下(production、beta、uat、moke、development...),不同的開發環境可能對應不同的后端接口或者其他配置,我們可以在客戶端程序中內置一個簡單的環境選擇功能來幫助我們更高效的開發。


          具體策略如下:



          • 在開發環境中,我們直接進入環境選擇頁面,讀取到選擇的環境后進行響應的重定向操作
          • 在菜單保留環境選擇入口,以便在開發過程中切換
          const envList=["moke", "beta", "development", "production"];
          exports.envList=envList;
          const urlBeta='https://wwww.xxx-beta.com';
          const urlDev='https://wwww.xxx-dev.com';
          const urlProp='https://wwww.xxx-prop.com';
          const urlMoke='https://wwww.xxx-moke.com';
          const path=require('path');
          const pkg=require(path.resolve(global.__dirname, 'package.json'));
          const build=pkg['build-config'];
          exports.handleEnv={
            build,
            currentEnv: 'moke',
            setEnv: function (env) {
              this.currentEnv=env
            },
            getUrl: function () {
              console.log('env:', build.env);
              if (build.env==='production' || this.currentEnv==='production') {
                return urlProp;
              } else if (this.currentEnv==='moke') {
                return urlMoke;
              } else if (this.currentEnv==='development') {
                return urlDev;
              } else if (this.currentEnv==="beta") {
                return urlBeta;
              }
            },
            isDebugger: function () {
              return build.env==='development'
            }
          }
          復制代碼

          十三、打包

          最后也是最重要的一步,將寫好的代碼打包成可運行的.app或.exe可執行文件。

          這里我把打包氛圍兩部分來做,渲染進程打包和主進程打包。

          13.1 渲染進程打包和升級

          一般情況下,我們的大部分業務邏輯代碼是在渲染進程完成的,在大部分情況下我們僅僅需要對渲染進程進行更新和升級而不需要改動主進程代碼,我們渲染進程的打包實際上和一般的web項目打包沒有太大差別,使用webpack打包即可。

          這里我說說渲染進程單獨打包的好處:

          打包完成的html和js文件,我們一般要上傳到我們的前端靜態資源服務器下,然后告知服務端我們的渲染進程有代碼更新,這里可以說成渲染進程單獨的升級。

          注意,和殼的升級不同,渲染進程的升級僅僅是靜態資源服務器上html和js文件的更新,而不需要重新下載更新客戶端,這樣我們每次啟動程序的時候檢測到離線包有更新,即可直接刷新讀取最新版本的靜態資源文件,即使在程序運行過程中要強制更新,我們的程序只需要強制刷新頁面讀取最新的靜態資源即可,這樣的升級對用戶是非常友好的。

          這里注意,一旦我們這樣配置,就意味著渲染進程和主進程打包升級的完全分離,我們在啟動主窗口時讀取的文件就不應該再是本地文件,而是打包完成后放在靜態資源服務器的文件。

          為了方便開發,這里我們可以區分本地和線上加載不同的文件:

          function getVersion (mac,current){
            // 根據設備mac和當前版本獲取最新版本
          }
          export default function () {
            if (build.env==='production') {
              const version=getVersion (mac,current);
              return 'https://www.xxxserver.html/electron-react/index_'+version+'.html';
            }
            return url.format({
              protocol: 'file:',
              pathname: path.join(__dirname, 'env/environment.html'),
              slashes: true,
              query: { debugger: build.env==="development" }
            });
          }
          復制代碼

          具體的webpack配置這里就不再貼出,可以到我的github electron-react的/scripts目錄下查看。

          這里需要注意,在開發環境下我們可以結合webpack的devServer和electron命令來啟動app:

            devServer: {
              contentBase: './assets/',
              historyApiFallback: true,
              hot: true,
              port: PORT,
              noInfo: false,
              stats: {
                colors: true,
              },
              setup() {
                spawn(
                  'electron',
                  ['.'],
                  {
                    shell: true,
                    stdio: 'inherit',
                  }
                )
                  .on('close', ()=> process.exit(0))
                  .on('error', e=> console.error(e));
              },
            },//...
          復制代碼

          13.2 主進程打包

          主進程,即將整個程序打包成可運行的客戶端程序,常用的打包方案一般有兩種,electron-packager和electron-builder。

          electron-packager在打包配置上我覺得有些繁瑣,而且它只能將應用直接打包為可執行程序。

          這里我推薦使用electron-builder,它不僅擁有方便的配置 protocol 的功能、內置的 Auto Update、簡單的配置 package.json 便能完成整個打包工作,用戶體驗非常不錯。而且electron-builder不僅能直接將應用打包成exe app等可執行程序,還能打包成msi dmg等安裝包格式。

          你可以在package.json方便的進行各種配置:

           "build": {
             "productName": "electron-react", // app中文名稱
             "appId": "electron-react",// app標識
             "directories": { // 打包后輸出的文件夾
               "buildResources": "resources",
               "output": "dist/"
             }
             "files": [ // 打包后依然保留的源文件
               "main_process/",
               "render_process/",
             ],
             "mac": { // mac打包配置
               "target": "dmg",
               "icon": "icon.ico"
             },
             "win": { // windows打包配置
               "target": "nsis",
               "icon": "icon.ico"
             },
             "dmg": { // dmg文件打包配置
               "artifactName": "electron_react.dmg",
               "contents": [
                 {
                   "type": "link",
                   "path": "/Applications",
                   "x": 410,
                   "y": 150
                 },
                 {
                   "type": "file",
                   "x": 130,
                   "y": 150
                 }
               ]
             },
             "nsis": { // nsis文件打包配置
               "oneClick": false,
               "allowToChangeInstallationDirectory": true,
               "shortcutName": "electron-react"
             },
           }
          復制代碼

          執行electron-builder打包命令時,可指定參數進行打包。

           --mac, -m, -o, --macos   macOS打包
           --linux, -l              Linux打包
           --win, -w, --windows     Windows打包
           --mwl                    同時為macOS,Windows和Linux打包
           --x64                    x64 (64位安裝包)
           --ia32                   ia32(32位安裝包) 
          復制代碼

          關于主進程的更新你可以使用electron-builder自帶的Auto Update模塊,在electron-react也實現了手動更新的模塊,由于篇幅原因這里就不再贅述,如果有興趣可以到我的github查看main下的update模塊。

          13.3 打包優化

          electron-builder打包出來的App要比相同功能的原生客戶端應用體積大很多,即使是空的應用,體積也要在100mb以上。原因有很多:

          第一點;為了達到跨平臺的效果,每個Electron應用都包含了整個V8引擎和Chromium內核。

          第二點:打包時會將整個node_modules打包進去,大家都知道一個應用的node_module體積是非常龐大的,這也是使得Electron應用打包后的體積較大的原因。

          第一點我們無法改變,我們可以從第二點對應用體積進行優化:Electron在打包時只會將denpendencies的依賴打包進去,而不會將 devDependencies 中的依賴進行打包。所以我們應盡可能的減少denpendencies中的依賴。在上面的進程中,我們使用webpack對渲染進程進行打包,所以渲染進程的依賴全部都可以移入devDependencies。

          另外,我們還可以使用雙packajson.json的方式來進行優化,把只在開發環境中使用到的依賴放在整個項目的根目錄的package.json下,將與平臺相關的或者運行時需要的依賴裝在app目錄下。具體詳見two-package-structure。

          參考

          本項目源碼地址:https://github.com/ConardLi/electron-react

          小結

          希望你閱讀本篇文章后可以達到以下幾點:

          • 了解Electron的基本運行原理
          • 掌握Electron開發的核心基礎知識
          • 了解Electron關于彈框、打印、保護、打包等功能的基本使用

          文中如有錯誤,歡迎在評論區指正,如果這篇文章幫助到了你,歡迎點贊和關注。

          想閱讀更多優質文章、可關注我的github博客,你的star?、點贊和關注是我持續創作的動力!

          github:https://github.com/ConardLi/ConardLi.github.io


          原鏈接:https://juejin.im/post/5cfd2ec7e51d45554877a59f


          主站蜘蛛池模板: 日韩电影一区二区三区| 无码人妻精品一区二区三区蜜桃| 国产免费私拍一区二区三区| 一区二区在线电影| 国产成人一区二区三区视频免费| 久久精品一区二区三区不卡| 日韩精品无码一区二区三区| 在线观看亚洲一区二区| 国产精品一区视频| 日韩精品一区二区三区中文字幕 | 国产伦理一区二区三区| 一区二区日韩国产精品| 无码人妻久久久一区二区三区 | 一本久久精品一区二区| 精品日韩一区二区| 精品福利一区二区三区| 成人免费观看一区二区| 天天躁日日躁狠狠躁一区| 嫩B人妻精品一区二区三区| 久久se精品一区二区国产| 国产成人精品日本亚洲专一区 | 亚洲国产一区二区三区| 亚洲综合色自拍一区| 手机福利视频一区二区| 香蕉视频一区二区三区| 精品动漫一区二区无遮挡| 一区二区免费国产在线观看| 91精品一区二区综合在线| 美女视频黄a视频全免费网站一区 美女免费视频一区二区 | 国产成人av一区二区三区不卡 | 色噜噜狠狠一区二区三区果冻| 亚洲日韩AV无码一区二区三区人| 国产午夜精品一区二区三区不卡| 亚洲欧美日韩国产精品一区| 不卡无码人妻一区三区音频| 国产免费私拍一区二区三区| 在线播放国产一区二区三区| 久久se精品一区二区影院| 国产成人精品亚洲一区| 久久伊人精品一区二区三区| 无码8090精品久久一区|