跨平台桌面应用开发(四)

原文:zh.annas-archive.org/md5/FAEC8292A2BD4C155C2816C53DE9AEF2

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:使用 NW.js 创建屏幕捕捉器:增强、工具和测试

在第五章中,使用 NW.js、React 和 Redux 创建屏幕捕捉器-规划、设计和开发,我们应用了 Redux 存储来管理应用程序状态。现在,我们将看看如何使用中间件来为工具化 Redux,并如何对 Redux 进行单元测试。

然而,本章的主要目标是最终教会我们的屏幕捕捉器如何拍摄截图和录制屏幕录像。为此,您将学习如何使用 WebRTC API 来捕获和记录媒体流。我们将通过使用画布从流中生成静止帧图像。我们将实践通知 API,以通知用户有关执行的操作,而不管焦点在哪个窗口。我们将向系统托盘添加菜单,并将其与应用程序状态绑定。我们将通过全局键盘快捷键使捕捉操作可用。

工具化 Redux

在第五章*,* 使用 NW.js、React 和 Redux 创建屏幕捕捉器-规划、设计和开发,您已经学会了 Redux 状态容器的基本知识。我们使用 Redux 构建了一个功能原型。但是,在构建自己的应用程序时,您可能需要知道状态树的变化发生的时间和内容。

幸运的是,Redux 接受中间件模块来处理横切关注点。这个概念与 Express 框架的概念非常相似。我们可以通过挂接第三方模块来扩展 Redux,当一个操作被分派但尚未到达减速器时。编写自定义记录器并没有太多意义,因为已经有很多可用的记录器(bit.ly/2qINXML)。例如,为了跟踪状态树中的更改,我们可以使用redux-diff-logger模块,它只报告状态的差异,这样更容易阅读。因此,我们将安装该软件包(npm i -S redux-diff-logger)并在入口脚本中添加几行代码:

./js/app.jsx

import { createStore, applyMiddleware, compose } from "redux"; 
import logger from 'redux-diff-logger'; 
const storeEnhancer = compose( 
        applyMiddleware( logger ) 
      ); 

const store = createStore( appReducer, storeEnhancer ); 

在这里,我们从redux-diff-logger中导出logger,并将其传递给redux模块的applyMiddleware函数,以创建一个存储增强器。存储增强器将给定的中间件应用于存储的dispatch方法。使用reduxcompose函数,我们可以组合多个增强器。我们将导数作为第二个参数传递给createStore函数。

现在,我们可以构建项目并启动它。我们可以在 UI 中进行一些操作,并查看 DevTools。JavaScript 控制台面板将输出我们引起的状态差异:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

通过 redux-diff-logger 中间件,我们在 DevTools 的 JavaScript 控制台中收到报告,当我们执行任何导致状态更改的操作时。例如,我们修改了截图文件名模板,这立即反映在控制台中。实际上,我们收到了一个全新的状态树对象,但 redux-diff-logger 足够聪明,只显示我们真正感兴趣的内容 - 状态的差异。

Redux DevTools

记录报告已经是一件事,但如果我们能够获得像DevTools这样的工具与状态进行交互,那将更有用。第三方软件包redux-devtools带来了一个可扩展的环境,支持状态实时编辑和时间旅行。我们将与另外两个模块redux-devtools-log-monitorredux-devtools-dock-monitor一起研究它。第一个允许我们检查状态和时间旅行。第二个是一个包装器,当我们按下相应的热键时,将 Redux DevTools UI 停靠到窗口边缘。为了看到它的效果,我们将创建一个新的组件来描述 DevTools:

./js/Components/DevTools.jsx

import React from "react"; 
import { createDevTools } from "redux-devtools"; 
import LogMonitor from "redux-devtools-log-monitor"; 
import DockMonitor from "redux-devtools-dock-monitor"; 

const DevTools = createDevTools( 
  <DockMonitor toggleVisibilityKey="ctrl-h" 
               changePositionKey="ctrl-q" 
               defaultPosition="bottom" 
               defaultIsVisible={true}> 
    <LogMonitor theme="tomorrow" /> 
  </DockMonitor> 
); 

export default DevTools; 

我们使用createDevTools函数来创建组件。它接受 JSX,我们可以通过DockMonitor的 props 配置 React DevTools UI 的可见性和位置,以及LogMonitor中的颜色主题。

派生的组件公开了instrument方法,它作为存储增强器返回。因此,我们可以将其传递给compose函数:

./js/app.jsx

import DevTools from "./Components/DevTools.jsx"; 

const storeEnhancer = compose( 
        applyMiddleware( logger ), 
        DevTools.instrument() 
      ); 

const store = createStore( appReducer, storeEnhancer ); 

DevTools组件本身中,我们必须将其添加到 DOM 中:

render(<Provider store={store}> 
  <div> 
    <App /> 
    <DevTools /> 
  </div> 
 </Provider>, document.querySelector( "root" ) ); 

现在,当我们运行应用程序时,我们可以看到 dock。我们可以按下Ctrl + Q来改变它的位置,按下Ctrl + H来隐藏或显示它:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

单元测试 Redux

我们已经在第四章中玩过 Jest 测试框架,Chat System with Electron and React: Enhancement, Testing, and Delivery(编写单元测试部分)。Redux 引入了新的概念,比如动作和减速器。现在,我们要对它们进行单元测试。

正如你可能记得的,要运行 Jest,我们需要配置 Babel:

.babelrc

{ 
  "presets": [ 
     ["env", { 
      "targets": { "node": 7 }, 
      "useBuiltIns": true 
    }], 
    "react", 
    "stage-3" 
  ], 

  "plugins": [ 
    "transform-class-properties", 
    "transform-decorators-legacy" 
  ] 
} 

同样,使用env预设,我们针对 Node.js 7 上的 Babel,并启用了在 webpack 配置中使用的额外插件。

测试动作创建者

实际上,动作创建者非常简单,因为它们是纯函数。我们根据函数接口传入输入并验证输出:

./js/Actions/index.spec.js

import { createStore } from "redux"; 
import { toggleRecording } from "./index"; 

describe( "Action creators", () => { 
  describe( "toggleRecording", () => { 
    it( "should return a valid action", () => { 
      const FLAG = true, 
            action = toggleRecording( FLAG ); 
            expect( action.payload ).toEqual( { toggle: FLAG } ); 
    }); 
  }); 
}); 

我们已经为toggleRecording函数编写了一个测试。我们断言这个函数产生的动作对象在 payload 中有{ toggle: FLAG }。正如前一章所述,任何动作都应该有一个强制属性type。当我们在调用redux-act模块的createAction函数时省略描述时,派生的动作创建者将产生具有动态生成标识符的动作,这几乎无法测试。然而,我们给它一个字符串作为第一个参数,例如TOGGLE_RECORDING

  const toggleRecording = createAction( "TOGGLE_RECORDING", ( toggle ) => ({ toggle }) ); 

this becomes the unique identifier and therefore we can expect it in type property. 

expect( action.type ).toEqual( "TOGGLE_RECORDING" ); 

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我们可以以几乎相同的方式测试当前应用程序中的每个动作创建者。

测试减速器

减速器和动作创建者都是纯函数。它们接受最后的状态树对象和分派的动作作为参数,并产生一个新的状态树对象。因此,在测试减速器时,我们正在检查给定的动作是否按预期修改了状态:

./js/Reducers/index.spec.js

import { createStore } from "redux"; 
import { createReducer } from "redux-act"; 
import { TAB_SCREENSHOT, SCREENSHOT_DEFAULT_FILENAME, ANIMATION_DEFAULT_FILENAME } from "../Constants"; 
import { appReducer } from "./index"; 

describe( "appReducer", () => { 
  it( "should return default state", () => { 
    const DEFAULT_STATE = { 
      isRecording: false, 
      activeTab: TAB_SCREENSHOT, 
      screenshotFilename: SCREENSHOT_DEFAULT_FILENAME, 
      animationFilename: ANIMATION_DEFAULT_FILENAME, 
      screenshotInputError: "", 
      animationInputError: "" 
    }; 
    expect( appReducer() ).toEqual( DEFAULT_STATE ); 
  }); 

 }); 

对于 Redux 来说,第一次调用我们的减速器时,状态是undefined。我们期望减速器接受一个预定义对象作为默认状态。因此,如果我们不带参数调用该函数,它应该在入口点接收默认状态并在没有给定动作的情况下返回它而不进行修改。

另一方面,我们可以导入一个动作创建者:

import { toggleRecording } from "../Actions"; 

创建一个动作并将其传递给减速器:

it( "should return a new state for toggleRecording action", () => { 
    const FLAG = true, 
          action = toggleRecording( FLAG ), 
          newState = appReducer( undefined, action ); 
    expect( newState.isRecording ).toEqual( FLAG ); 
  }); 

因此,我们测试减速器是否产生了一个新的状态,根据给定的动作进行了更改。调用toggleRecording(true)创建的动作应该将状态对象属性isRecording设置为 true。这就是我们在测试中断言的内容:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

截取屏幕截图

先前创建的静态原型可能看起来很花哨,但用处不大。我们需要一个能够截取屏幕截图和录制屏幕录像的服务。

如果是关于应用程序窗口的屏幕截图,我们可以简单地使用 NW.js 的 API:

import * as fs from "fs"; 
function takeScreenshot( filePath ){ 
  appWindow.capturePage(( img ) => { 
    fs.writeFileSync( filePath, img, "base64" ); 
  }, { 
    format : "png", 
    datatype : "raw" 
  }); 
} 

但是我们需要屏幕截图,因此我们必须获得显示输入的访问权限。W3C 包括了一份规范草案,“媒体捕获和流”(bit.ly/2qTtLXX),其中描述了捕获显示媒体的 API(mediaDevices.getDisplayMedia)。不幸的是,在撰写本文时,它尚未得到 NW.js 或任何浏览器的支持。然而,我们仍然可以使用webkitGetUserMedia,它可以流式传输桌面输入。这个 API 曾经是被称为 WebRTC 的技术的一部分(webrtc.org),旨在实现实时视频、音频和数据通信。

然而,目前它已从规范中删除,但仍然在 NW.js 和 Electron 中可用。看起来我们真的没有选择,所以我们就这样做吧。

webkitGetUserMedia接受所谓的MediaStreamConstraints对象,描述我们想要捕获的内容,并返回一个 promise。在我们的情况下,约束对象可能如下所示:

{ 
    audio: false, 
    video: { 
     mandatory: { 
      chromeMediaSource: "desktop", 
      chromeMediaSourceId: desktopStreamId, 
      minWidth: 1280, 
      maxWidth: 1920, 
      minHeight: 720, 
      maxHeight: 1080 
     } 
   } 
} 

我们禁用音频录制,为视频设置边界(webkitGetUserMedia根据您的显示分辨率确定合适的大小。当分辨率不符合范围时,会导致OverconstrainedError),并描述媒体来源。但是我们需要一个有效的媒体流 ID。我们可以从 NW.js API 中获取,例如:

nw.Screen.chooseDesktopMedia([ "window", "screen" ], ( mediaStremId ) => { 
      // mediaStremId 
    }); 

当所有内容结合在一起时,我们得到以下的服务:

./js/Service/Capturer.js

import * as fs from "fs"; 
const appWindow = nw.Window.get(); 

export default class Capturer { 

  constructor(){  
    nw.Screen.chooseDesktopMedia([ "window", "screen" ], ( id) => { 
      this.start( id ); 
    }); 
  } 

  takeScreenshot( filename ){ 
    console.log( "Saving screensho" ); 
  } 

  start( desktopStreamId ){ 
    navigator.webkitGetUserMedia({ 
        audio: false, 
        video: { 
          mandatory: { 
            chromeMediaSource: "desktop", 
            chromeMediaSourceId: desktopStreamId, 
            minWidth: 1280, 
            maxWidth: 1920, 
            minHeight: 720, 
            maxHeight: 1080 
          } 
        } 
      }, ( stream ) => { 
        // stream to HTMLVideoElement 

      }, ( error ) => { 
        console.log( "navigator.getUserMedia error: ", error ); 
      }); 

  } 
} 

运行时,我们会得到一个对话框提示我们选择媒体来源:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我不太喜欢这个用户体验。我宁愿让它检测桌面媒体。我们可以通过以下方法实现:

static detectDesktopStreamId( done ){ 
    const dcm = nw.Screen.DesktopCaptureMonitor; 
    nw.Screen.Init(); 
    // New screen target detected 
    dcm.on("added", ( id, name, order, type ) => { 
      // We are interested only in screens 
      if ( type !== "screen" ){ 
        return; 
      } 
      done( dcm.registerStream( id ) ); 
      dcm.stop(); 
    }); 
    dcm.start( true, true ); 
  } 

我们使用 NW.js API 的DesktopCaptureMonitor来检测可用的媒体设备,拒绝应用窗口(类型为"screen"),并使用registerStream方法获取媒体流 ID。现在,我们用我们自定义的方法detectDesktopStreamId替换 NW.js API 的chooseDesktopMedia

  constructor(){ 
    Capturer.detectDesktopStreamId(( id ) => { 
      this.start( id ); 
    }); 
  } 

好吧,我们设法接收到了流。我们必须将它指向某个地方。我们可以创建一个隐藏的HTMLVideoElement并将其用作视频流接收器。我们将这个功能封装在一个单独的模块中:

./js/Service/Capturer/Dom.js

export default class Dom { 

  constructor(){ 
    this.canvas = document.createElement("canvas") 
    this.video = Dom.createVideo(); 
  } 

   static createVideo(){ 
    const div = document.createElement( "div" ), 
          video = document.createElement( "video" ); 
    div.className = "preview"; 
    video.autoplay = true; 
    div.appendChild( video ); 
    document.body.appendChild( div ); 
    return video; 
  } 

 } 

在构造过程中,该类创建一个新的 DIV 容器和其中的视频元素。容器被附加到 DOM。我们还需要用 CSS 支持新元素:

./assets/main.css

.preview { 
  position: absolute; 
  left: -999px; 
  top: -999px; 
  width: 1px; 
  height: 1px; 
  overflow: hidden; 
}  

基本上,我们将容器移出视图。因此,视频将被流式传输到隐藏的HTMLVideoElement中。现在的任务是捕获静止帧并将其转换为图像。我们可以用以下的技巧来做到这一点:

  getVideoFrameAsBase64() { 
    const context = this.canvas.getContext("2d"), 
          width = this.video.offsetWidth, 
          height = this.video.offsetHeight; 

    this.canvas.width = width; 
    this.canvas.height = height; 

    context.drawImage( this.video, 0, 0, width, height ); 

    return this.canvas.toDataURL("image/png") 
      .replace( /^data:image\/png;base64,/, "" ); 

  } 

我们创建一个与视频大小匹配的画布上下文。通过使用上下文方法drawImage,我们从视频流中绘制图像。最后,我们将画布转换为数据 URI,并通过去除data:scheme前缀来获取 Base64 编码的图像。

我们将我们的Dom模块实例注入Capturer服务作为依赖项。为此,我们需要修改构造函数:

./js/Service/Capturer.js

constructor( dom ){     
     this.dom = dom; 
    Capturer.detectDesktopStreamId(( id ) => { 
      this.start( id ); 
    }); 
  } 

我们还需要将媒体流转发到HTMLVideoElement中:

start( desktopStreamId ){ 
    navigator.webkitGetUserMedia( /* constaints */, ( stream ) => { 
        this.dom.video.srcObject = stream; 
      }, ( error ) => { 
        console.log( "navigator.getUserMedia error: ", error ); 
      }); 
} 

我们还添加了一个保存屏幕截图的方法:

takeScreenshot( filename ){ 
    const base64Data = this.dom.getVideoFrameAsBase64(); 
    fs.writeFileSync( filename, base64Data, "base64" ); 
  } 

现在,当在组件中调用这个方法时,图像会悄悄地保存。说实话,这并不是很用户友好。用户按下按钮,却没有收到关于图像是否真的保存了的信息。我们可以通过显示桌面通知来改善用户体验:

const ICON = `./assets/icon-48x48.png`; 
//...  
takeScreenshot( filename ){ 
    const base64Data = this.dom.getVideoFrameAsBase64(); 
    fs.writeFileSync( filename, base64Data, "base64" ); 
    new Notification( "Screenshot saved",  { 
      body: `The screenshot was saved as ${filename}`, 
      icon: `./assets/icon-48x48.png` 
    }); 

  } 

现在,当新创建的屏幕截图被保存时,相应的消息会在系统级别显示。因此,即使应用程序窗口被隐藏(例如,我们使用系统托盘或快捷方式),用户仍然会收到通知:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

录制屏幕截图

实际上,在构建用于截图的服务时,我们已经完成了大部分录屏的工作。我们已经有了webkitGetUserMedia提供的MediaStream对象。我们只需要一种方法来定义录制的开始和结束,并将收集的帧保存在视频文件中。这就是我们可以从MediaStream Recording API 中受益的地方,它捕获由MedaStreamHTMLMediaElement(例如<video>)产生的数据,以便我们可以保存它。因此,我们再次修改服务:

./js/Service/Capturer.js

//... 
const toBuffer = require( "blob-to-buffer" ); 
//... 
start( desktopStreamId ){ 
    navigator.webkitGetUserMedia(/* constaints */, ( stream ) => { 
        let chunks = []; 
        this.dom.video.srcObject = stream; 
        this.mediaRecorder = new MediaRecorder( stream ); 
        this.mediaRecorder.onstop = ( e ) => { 
          const blob = new Blob( chunks, { type: "video/webm" }); 
          toBuffer( blob, ( err, buffer ) => { 
            if ( err ) { 
              throw err; 
            } 
            this.saveAnimationBuffer( buffer ); 
            chunks = []; 
          }); 
        } 
        this.mediaRecorder.ondataavailable = function( e ) { 
          chunks.push( e.data ); 
        } 

      }, ( error ) => { 
        console.log( "navigator.getUserMedia error: ", error ); 
      }); 

  } 

收到MediaStream后,我们使用它来创建MediaRecorder的实例。我们订阅了实例的dataavailable事件。处理程序接受一个 Blob(表示流的一帧的类似文件的对象)。为了制作视频,我们需要一系列的帧。因此,我们将每个接收到的 Blob 推送到 chunks 数组中。我们还为停止事件订阅了一个处理程序,它从收集到的 chunks 中创建了一个webm类型的新 Blob。因此,我们有一个表示屏幕录像的 Blob,但我们不能直接将其保存在文件中。

对于二进制数据流,Node.js 将期望我们提供一个 Buffer 类的实例。我们使用blob-to-buffer包将 Blob 转换为 Buffer。

在这段代码中,我们依赖于两个事件,dataavailablestop。第一个在我们启动录制时触发,第二个在我们停止时触发。这些操作是公开的:

record( filename ){ 
    this.mediaRecorder.start(); 
    this.saveAnimationBuffer = ( buffer ) => { 
      fs.writeFileSync( filename, buffer, "base64" ); 
      new Notification( "Animation saved",  { 
        body: `The animation was saved as ${filename}`, 
        icon: ICON 
      }); 
    } 
  } 

  stop(){ 
    this.mediaRecorder.stop(); 
  } 

当调用record方法时,MediaRecorder实例开始录制,相反,使用stop方法停止该过程。此外,我们定义了saveAnimationBuffer回调函数,当录制停止时将被调用(this.mediaRecorder.onstop)。回调函数(saveAnimationBuffer)接收到录制屏幕的二进制流buffer参数,并使用fs核心模块的writeFileSync方法保存它。与截图类似,在保存屏幕录像时,我们创建一个桌面通知,通知用户已执行的操作。

服务几乎准备好了。但是正如您从我们的线框图中记得的那样,屏幕捕获器接受文件名的模板,例如screenshot{N}.pnganimation{N}.webm,其中{N}是文件索引的占位符。因此,我想将文件系统操作封装在专用类Fsys中,我们可以根据需要处理模板:

./js/Service/Capturer/Fsys.js

import * as fs from "fs"; 

export default class Fsys { 

  static getStoredFiles( ext ){ 
    return fs.readdirSync( "." ) 
      .filter( (file) => fs.statSync( file ).isFile() 
          && file.endsWith( ext ) ) || [ ]; 
  } 

  saveFile( filenameRaw, data, ext ){ 
    const files = Fsys.getStoredFiles( ext ), 
          // Generate filename of the pattern like screenshot5.png 
          filename = filenameRaw.replace( "{N}", files.length + 1 ); 
    fs.writeFileSync( filename, data, "base64" ); 
    return filename; 
  } 
} 

这个类有一个静态方法getStoredFiles,它返回工作目录中给定类型(扩展名)的所有文件的数组。在saveFile方法中保存文件之前,我们获取之前存储的文件列表,并计算{N}的值为files.length + 1。因此,第一个截图将被保存为screenshot1.png,第二个为screenshot2.png,依此类推。

我们在Capturer服务中注入的Fsys实例:


export default class Capturer { 

  constructor( fsys, dom ){ 
    this.fsys = fsys; 
    this.dom = dom; 
    Capturer.detectDesktopStreamId(( id ) => { 
      this.start( id ); 
    }); 
  } 

我们将在入口脚本中实例化服务:

./func-services/js/app.jsx

import Fsys from "./Service/Capturer/Fsys"; 
import Dom from "./Service/Capturer/Dom"; 
import Capturer from "./Service/Capturer"; 

const capturer = new Capturer( new Fsys(), new Dom() ); 

render(<Provider store={store}> 
  <App capturer={capturer} /> 
 </Provider>, document.querySelector( "root" ) ); 

我们导入Capturer类和依赖项。在构造Capturer时,我们将FsysDom的实例传递给它。我们将派生的Capturer实例与 props 一起传递给App组件。

因此,服务的实例到达ScreenshotTab组件,我们可以用它来拍摄截图:

./js/Components/ScreenshotTab.jsx

// Handle when clicked CAPTURE 
 onCapture = () => { 
    const { states } = this.props; 
    this.props.capturer.takeScreenshot( states.screenshotFilename ); 
  } 

类似地,在AnimationTab中,我们应用了相应处理程序的实例的recordstop方法:

./js/Components/AnimationTab.jsx

// Handle when clicked RECORD 
onRecord = () => { 
    const { states } = this.props; 
    this.props.capturer.record( states.animationFilename ); 
    this.props.actions.toggleRecording( true ); 
  } 
 // Handle when clicked STOP 
  onStop = () => { 
    this.props.capturer.stop(); 
    this.props.actions.toggleRecording( false ); 
  } 

现在,在构建应用程序之后,我们可以使用它来进行截图和录制屏幕录像:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从我们的图像中,我们可以观察到拍摄截图和录制屏幕录像的按钮是窗口 UI 的一部分。但是,我们还需要提供隐藏窗口的功能。那么在应用程序隐藏时如何进行捕获操作呢?答案与系统托盘有关。

利用系统托盘

第二章,使用 NW.js 创建文件资源管理器-增强和交付中,我们已经研究了在系统托盘中添加和管理应用程序菜单。简而言之,我们使用nw.MenuItem创建菜单项,将它们添加到nw.Menu实例中,并将菜单附加到nw.Tray。因此,托盘菜单的样板可能如下所示:

./js/Service/Tray.js

const appWindow = nw.Window.get(); 

export default class Tray { 

  tray = null; 

  constructor( ) { 
    this.title = nw.App.manifest.description; 
    this.removeOnExit(); 
  } 

  getItems = () => { 
    return [ /* */ ]; 
  } 

  render(){ 
    if ( this.tray ) { 
      this.tray.remove(); 
    } 

    const icon = "./assets/" + 
      ( process.platform === "linux" ? "icon-48x48.png" : "icon-
      32x32.png" ); 

    this.tray = new nw.Tray({ 
      title: this.title, 
      icon, 
      iconsAreTemplates: false 
    }); 

    const menu = new nw.Menu(); 
    this.getItems().forEach(( item ) => menu.append( new nw.MenuItem( 
    item ))); 

    this.tray.menu = menu; 

  } 

  removeOnExit(){ 
    appWindow.on( "close", () => { 
      this.tray.remove(); 
      appWindow.hide(); // Pretend to be closed already 
      appWindow.close( true ); 
    }); 
    // do not spawn Tray instances on page reload 
    window.addEventListener( "beforeunload", () => this.tray.remove(), 
    false ); 
  } 

} 

对于这个应用程序,我们需要以下菜单项:

Take screenshot 
Start recording 
Stop recording 
--- 
Open 
Exit 

在这里,Start recordingStop recording根据状态isRecording属性启用。此外,我们需要Capturer实例和状态属性screenshotFilenameanimationFilename来在用户请求时运行捕获操作。因此,我们在Tray构造函数中注入了这两个依赖项:

./js/Service/Tray.js

import { toggleRecording } from "../Actions"; 
import { SCREENSHOT_DEFAULT_FILENAME, ANIMATION_DEFAULT_FILENAME } from "../Constants"; 

export default class Tray { 
 // default file names 
  screenshotFilename = SCREENSHOT_DEFAULT_FILENAME; 
  animationFilename = ANIMATION_DEFAULT_FILENAME; 
  isRecording = false;  

  constructor( capturer, store ) { 
    this.capturer = capturer; 
    this.store = store; 
} 

此外,我们定义了一些实例属性。screenshotFilenameanimationFilename将从状态中接收最新的用户定义的文件名模板。当状态改变时,属性isRecording将接收相应的值。为了接收状态更新,我们订阅存储更改:

constructor( capturer, store ) { 
    //... 
    store.subscribe(() => { 
      const { isRecording, screenshotFilename, animationFilename } = 
      store.getState(); 
      this.screenshotFilename = screenshotFilename; 
      this.animationFilename = animationFilename; 

      if ( this.isRecording === isRecording ) { 
        return; 
      } 
      this.isRecording = isRecording; 
      this.render(); 
    });    

  } 

在回调中,我们将状态中的实际isRecording值与实例属性isRecording中的早期存储值进行比较。这样,我们就知道了isRecording何时真正改变。只有在这种情况下,我们才会更新菜单。

最后,我们可以在getItems方法中填充菜单项选项数组:

getItems = () => { 
    return [ 
      { 
        label: `Take screenshot`, 
        click: () => this.capturer.takeScreenshot( 
        this.screenshotFilename ) 
      }, 
      { 
        label: `Start recording`, 
        enabled: !this.isRecording, 
        click: () => { 
          this.capturer.record( this.animationFilename ); 
          this.store.dispatch( toggleRecording( true ) ); 
        } 
      }, 
      { 
        label: `Stop recording`, 
        enabled: this.isRecording, 
        click: () => { 
          this.capturer.stop(); 
          this.store.dispatch( toggleRecording( false ) ); 
        } 
      }, 
      { 
        type: "separator" 
      }, 
      { 
        label: "Open", 
        click: () => appWindow.show() 
      }, 
      { 
        label: "Exit", 
        click: () => appWindow.close() 
      } 
    ]; 
  } 

我们使用应用程序窗口的close方法退出,并使用show方法恢复窗口(如果它被隐藏)。我们依赖传入的Capturer实例来捕获操作。我们还通过分发(store.dispatchtoggleRecording动作来更新状态。

现在我们在入口脚本中实例化Tray类并调用render方法:

./js/app.jsx

import Shortcut from "./Service/Shortcut" 
const tray = new Tray( capturer, store ); 
tray.render(); 

运行应用程序时,我们可以在系统通知区域看到屏幕捕获菜单:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注册全局键盘快捷键

托盘中的菜单是一种解决方案,但实际上,我们有一个选项可以执行捕获操作,即使不打开菜单。NW.js 允许我们分配全局键盘快捷键:

  const shortcut = new nw.Shortcut({ 
      key: "Shift+Alt+4", 
      active: () => {} 
      failed: console.error 
    }); 

nw.App.registerGlobalHotKey( shortcut ); 
appWindow.on( "close", () => nw.App.unregisterGlobalHotKey( shortcut ) ); 
window.addEventListener( "beforeunload", () => nw.App.unregisterGlobalHotKey( shortcut ), false ); 

我们使用nw.Shortcut来创建代表快捷键的对象。使用nw.App.registerGlobalHotKey注册快捷键。当应用程序关闭或重新加载时,我们使用nw.App.unregisterGlobalHotKey取消注册快捷键。

这将引入以下服务:

./js/Service/Shortcut.js

const appWindow = nw.Window.get(); 
import { toggleRecording } from "../Actions"; 
import { SCREENSHOT_DEFAULT_FILENAME, ANIMATION_DEFAULT_FILENAME, 
  TAKE_SCREENSHOT_SHORTCUT, RECORD_SHORTCUT, STOP_SHORTCUT } from "../Constants"; 

export default class Shortcut { 

 screenshotFilename = SCREENSHOT_DEFAULT_FILENAME; 
 animationFilename = ANIMATION_DEFAULT_FILENAME; 
 isRecording = false; 

 constructor( capturer, store ) { 

    this.capturer = capturer; 
    this.store = store; 

    store.subscribe(() => { 
      const { isRecording, screenshotFilename, animationFilename } = 
      store.getState(); 
      this.screenshotFilename = screenshotFilename; 
      this.animationFilename = animationFilename; 
      this.isRecording = isRecording; 
    }); 
 } 

 registerOne( key, active ){ 
    const shortcut = new nw.Shortcut({ 
      key, 
      active, 
      failed: console.error 
    }); 
    // Register global desktop shortcut, which can work without focus. 
    nw.App.registerGlobalHotKey( shortcut ); 
    appWindow.on( "close", () => nw.App.unregisterGlobalHotKey( 
    shortcut ) ); 
    window.addEventListener( "beforeunload", () => 
    nw.App.unregisterGlobalHotKey( shortcut ), false ); 
 } 

 registerAll(){ 
  this.registerOne( TAKE_SCREENSHOT_SHORTCUT, () => 
  this.capturer.takeScreenshot( this.screenshotFilename ) ); 
  this.registerOne( RECORD_SHORTCUT, () => { 
    if ( this.isRecording ) { 
      return; 
    } 
    this.capturer.record( this.animationFilename ); 
    this.store.dispatch( toggleRecording( true ) ); 
  }); 
  this.registerOne( STOP_SHORTCUT, () => { 
    if ( !this.isRecording ) { 
      return; 
    } 
    this.capturer.stop(); 
    this.store.dispatch( toggleRecording( false ) ); 
  }); 
 } 

} 

Tray类中的情况非常相似,我们注入了捕捉器和存储实例。通过第一个,我们可以访问捕捉操作,并使用第二个来访问全局状态。我们订阅状态更改以获取文件名模板和isRecording的实际值。registerOne方法基于给定的键和回调创建并注册一个快捷键实例,并订阅closebeforeunload事件以取消注册快捷键。在registerAll方法中,我们声明了我们的动作快捷键。快捷键的键我们将在常量模块中定义:

./js/Constants/index.js

export const TAKE_SCREENSHOT_SHORTCUT = "Shift+Alt+4"; 
export const RECORD_SHORTCUT = "Shift+Alt+5"; 
export const STOP_SHORTCUT = "Shift+Alt+6"; 

现在,我们还可以将键附加到托盘菜单项:

getItems = () => { 
 return  
    { 
      label: `Take screenshot (${TAKE_SCREENSHOT_SHORTCUT})`, 
   //... 

现在,当我们运行应用程序时,我们会得到以下托盘菜单:

![

我们可以通过点击标题栏左侧的隐藏窗口按钮来隐藏应用程序,并通过按下Shift + Alt + 4来截取屏幕截图,按下Shift + Alt + 5Shift + Alt + 6来开始和停止录制屏幕录像。

摘要

我们通过介绍 Redux 中间件来开始本章。作为示例,我们使用redux-diff-logger来监视存储中的变化。我们还插入了一系列工具(redux-devtools),使得可以在页面上启用类似 DevTools 的面板,用于检查存储并使用取消操作来回溯时间。最后,我们通过 Redux 来检查了动作创建者和减速器的单元测试。

在本章中,我们创建了Capturer服务,负责拍摄屏幕截图和录制屏幕录像。我们通过使用webkitGetUserMedia API 在MediaStream中实现了对桌面视频输入的捕获。利用 Canvas API,我们成功地从视频流中获取静止帧并将其转换为图像。对于视频录制,我们选择了MediaRecorder API。我们为截图和屏幕录像操作提供了相应的桌面通知。我们在系统托盘中实现了一个应用菜单,并将其绑定到存储中。为了即使在没有打开托盘菜单的情况下也能访问捕获操作,我们注册了全局键盘快捷键。

第七章:使用 Electron、TypeScript、React 和 Redux 创建 RSS 聚合器:规划、设计和开发

通过前面的章节,我们使用纯 JavaScript、React 和 React + Redux 创建了一个应用程序。现在,我们将使用最佳技术栈来开发大型可扩展的 Web 应用程序–TypeScript + React + Redux。我们将开发 RSS 聚合器。我认为这是一个很好的例子,可以展示 TypeScript 的实际应用,以及检查异步操作。此外,您还将学习使用新的组件库 React MDL。我们还将使用 SASS 语言编写自定义样式来扩展它。

应用蓝图

我们开发一个典型的工具,从可管理的来源列表中聚合联合内容。如果我们将需求分解为用户故事,我们会得到类似于这样的东西:

  • 作为用户,我可以看到先前添加的来源列表

  • 作为用户,我可以看到汇总内容

  • 作为用户,我可以通过在菜单中选择来源来过滤内容项

让我们再次使用WireframeSketcherwireframesketcher.com/)并将其放在线框上:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 作为用户,我可以在列表旁边打开项目链接

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 作为用户,我可以添加一个来源

  • 作为用户,我可以删除一个来源

  • 作为用户,我可以更新汇总内容

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

欢迎来到 TypeScript

在开发大型可扩展应用程序时,确保所有团队成员都遵循已建立的架构是至关重要的。在其他语言中,如 Java、C++、C#和 PHP,我们可以声明类型和接口。因此,除非新功能完全满足系统架构师预期的接口,否则无法使用。JavaScript 既没有严格的类型,也没有接口。因此,2012 年,微软的工程师开发了 JavaScript 的超集(ES2015)称为TypeScript。这种语言通过可选的静态类型扩展了 JavaScript,并编译回 JavaScript,因此可以被任何浏览器和操作系统接受。这类似于我们如何使用 Babel 将 ES.Next 编译为第五版 ECMAScript,但此外,它还为我们带来了一些不太可能在可预见的未来集成到 ECMAScript 中的功能。这种语言非常出色,并且在www.typescriptlang.org/docs/home.html有文档支持,并且提供了优秀的规范bit.ly/2qDmdXj。这种语言得到了主流 IDE 和代码编辑器的支持,并且可以通过插件集成到 Grunt、Gulp、Apache Maven、Gradle 等自动化工具中。一些主要的框架正在考虑迁移到 TypeScript,而 Angular 2+和 Dojo 2 已经采用了它。其他框架通过定义文件向 TypeScript 公开它们的接口。

作为静态类型检查的替代,可以选择使用 Facebook 的Flowflow.org)。与 TypeScript 不同,Flow 不是编译器,而是一个检查器。Flow 中的基本类型与 TypeScript 的类型非常相似,几乎使用相同的语法实现。Flow 还引入了高级类型,如数组、联合、交集和泛型,但是使用了自己的方式。根据 Facebook 的说法,他们创建 Flow 是因为“TypeScript 并没有像他们想要的那样建立在发现错误的基础上。”

为 TypeScript 设置开发环境

TypeScript 对开发体验做出了诱人的承诺。为什么不动动手,看看实际操作呢?首先,我们需要为即将到来的示例创建一个专用目录。我们通过运行npm init -y来初始化项目,并将typescript安装为开发依赖项:

npm i -D typescript

在清单的scripts部分,我们添加了一个用于使用 TypeScript 编译源代码的命令:

package.json

{ 
... 
"scripts": { 
    "build": "tsc" 
  }, 
... 
} 

我们需要让 TypeScript 知道我们究竟想要什么。我们将在配置文件中描述这一点:

tsconfig.json

{ 
  "compilerOptions": { 
    "target": "ES6", 
    "module": "CommonJS", 
    "moduleResolution": "node", 
    "sourceMap": true, 
    "outDir": "./build" 
  }, 

  "include": [ 
    "./**/*" 
  ], 
  "exclude": [ 
    "node_modules" 
  ] 
} 

在这里,我们将 TypeScript 编译器设置为在项目目录中的任何地方搜索ts源文件,但不包括node_modules。在compilerOptions中,我们指定了在编译期间希望如何处理我们的源文件。target字段设置为ES6,意味着 TypeScript 将编译为 ES6/ES2016 语法,这在所有现代浏览器中已经得到充分支持。在module字段中,我们使用CommonJS。因此,TypeScript 将源文件捆绑成符合 CommonJS 标准的模块,与 Node.js 环境兼容。在moduleResolution字段中,我们选择了 Node.js 模块解析风格。在outDir字段中,我们确定 TypeScript 将存储编译后的模块的位置。有关编译器选项的更多信息,请访问bit.ly/2t9fckV

基本类型

开发环境现在似乎已经准备好了,所以我们可以用一个基本的例子来试一试:

example.ts

let title: string = "RSS Aggregator"; 

我们使用 TypeScript 的类型注解功能来对变量设置约束。这很容易;我们只需扩展声明,使用所谓的声明空间,比如:type,其中 type 可以是基本类型(boolean、number、string、array、void、any 等),类、接口、类型别名、枚举和导入。在这里,我们应用了string,意味着 title 只接受字符串。

编译后使用npm run build,我们可以在./build目录中找到example.js文件,内容如下:

build/example.js

let title = "RSS Aggregator"; 

你会发现它并没有做太多事情;它只是移除了类型提示。这就是 TypeScript 的惊人之处 - 类型检查发生在编译时,并在运行时消失。因此,我们可以从 TypeScript 中受益,而不会对应用程序的性能产生任何影响。

好吧,让我们做一件不好的事,给变量设置一个违反给定约束的值:

example.ts

let title: string = "RSS Aggregator"; 
title = 1; 

编译时,我们收到了一个错误消息:

error TS2322: Type '1' is not assignable to type 'string'. 

嗯;TypeScript 在我们做错事时警告我们。更令人兴奋的是,如果你的 IDE 支持 TypeScript,你在输入时会立即得到通知。我建议对照列表bit.ly/2a8rmTl,选择最适合你的 IDE,如果你的 IDE 恰好不在列表中。我会推荐Alm(alm.tools),它是使用 TypeScript、React 和 Redux 的一个很好的例子。然而,我自己十年前就开始使用NetBeans(netbeans.org/),它从未让我失望过。它没有原生的 TypeScript 支持,但可以通过安装TypeScript Editor 插件(github.com/Everlaw/nbts)轻松获得。

让我们更多地使用类型注解。我们拿一个函数,并为入口和出口点定义一个契约:

example.ts

function sum( a: number, b: number ): number { 
  return a + b; 
} 
 let res = sum( 1, 1 ); 
console.log( res ); 

实际上,我们在这里声明函数接受两个数字,并应返回一个数字。现在,即使我们想给函数赋予与数字不同的任何类型,IDE 也会立即提醒我们:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

数组、普通对象和可索引类型

我相信,对于原始类型,情况或多或少是清楚的,但其他类型呢,比如数组?通过将基本类型与[]结合,我们定义了一个数组类型:

let arr: string[]; 

在这里,我们声明了变量arr,它是一个字符串数组。我们可以使用以下语法实现相同的效果:

let arr: Array<string>; 

或者,我们可以使用接口来实现:

interface StringArray { 
  [ index: number ]: string; 
} 
 const arr: StringArray = [ "one", "two", "tree" ]; 

通过使用所谓的索引签名来声明StringArray接口,我们对类型结构设置了约束。它接受数字索引和字符串值。换句话说,它是一个字符串数组。我们还可以进一步对数组长度设置约束:

interface StringArray { 
  [ index: number ]: string; 
  length: number; 
} 

至于普通对象,我们可以使用描述预期形状的接口:

interface MyObj {  
  foo: string; 
  bar: number; 
} 
let obj: MyObj; 

另一方面,我们可以使用对象类型文字内联设置约束:

let obj: { foo: string, bar: number }; 
// or 
function request( options: { uri: string, method: string } ): void { 
}

如果我们能够声明一个值对象(bit.ly/2khKSBg),我们需要确保不可变性。幸运的是,TypeScript 允许我们指定对象的成员为readonly

interface RGB { 
    readonly red: number; 
    readonly green: number; 
    readonly blue: number; 
} 
 let green: RGB = { red: 0, green: 128, blue: 0 }; 

我们可以访问百分比,例如RGB类型的颜色中的红色。但我们不能更改已声明颜色的 RGB 级别。如果我们尝试这样做,将会得到以下错误:

error TS2540: Cannot assign to 'red' because it is a constant or a read-only property. 

对于任意属性的对象,我们可以使用索引签名来定位字符串键:

interface DataMap { 
  [ key: string ]: any; 
} 

const map: DataMap = { foo: "foo", bar: "bar" }; 

请注意,在DataMap中,我们为成员类型设置了any。通过这样做,我们允许任何值类型。

函数类型

我们可以通过使用函数类型文字在函数上设置约束:

const showModal: (toggle: boolean) => void =  
  function( toggle )  { 
    console.log( toggle ); 
  } 

我觉得这相当令人沮丧,更喜欢使用接口:

interface Switcher { 
  (toggle: boolean): void; 
} 

const showModal:Switcher = ( toggle ) => { 
  console.log( toggle ); 
} 

showModal( true ); 

现在你可能会问,如果函数有可选参数怎么办?TypeScript 使定义可选参数非常简单。您只需要在参数后面加上一个问号:

function addOgTags(title: string, description?: string): string { 
  return ` 
    <meta property="og:title" content="${title}" /> 
    <meta property="og:description" content="${description || ""}" /> 
    } 

我们将description设置为可选,因此我们可以以两种方式调用该函数:

addOgTags( "Title" ); 
addOgTags( "Title", "Description" ); 

这些都不违反已声明的接口;到目前为止,我们给它字符串。

以相同的方式,我们可以定义可选对象成员:

interface IMeta { 
  title: string; 
  description?: string; 
} 

function addOgTags( meta: IMeta ): string { 
} 

类类型

在其他语言中,我们习惯将接口视为与类密切相关。TypeScript 带来了类似的开发体验。而且,虽然 Java 和 PHP 接口不能包含实例属性,TypeScript 没有这样的限制:

interface Starship { 
  speed: number;  
  speedUp( increment: number ): void; 
} 

class LightFreighter implements Starship { 
  speed: number = 0; 
  speedUp( increment: number ): void { 
    this.speed = this.speed + increment; 
  } 
} 

let millenniumFalcon = new LightFreighter(); 
millenniumFalcon.speedUp( 100 ); 

随着 ES2015/2016 的发展,类在 JavaScript 中被广泛使用。然而,TypeScript 允许我们设置成员的可访问性。因此,当我们允许从消费对象实例的代码中访问成员时,我们将成员声明为public。我们使用private来确保成员在其包含的类之外不可访问。此外,protected成员与private类似,只是它们可以在任何派生类实例中被访问:

class LightFreighter implements Starship { 
  private speed: number = 0; 
  public speedUp( increment: number ): void { 
    this.speed = this.speed + increment; 
  } 
} 

正如你所看到的,speed的值是硬编码的。如果我们的类在初始化期间可以配置初始速度,那就更好了。让我们进行重构:

class LightFreighter implements Starship { 
  constructor( private speed: number = 0 ) { 
  } 
  public speedUp( increment: number ): void { 
    this.speed = this.speed + increment; 
  } 
} 

在这里,我们使用了 TypeScript 的另一个我个人很激动的很好的特性。它被称为参数属性。我们经常声明私有属性,并从构造函数参数中填充它们。在 TypeScript 中,我们可以简单地在参数前面加上一个可访问性修饰符,它将导致一个相应命名的属性,接受参数的值。因此,在前面的代码中,使用private speed在参数列表中,我们声明了speed参数,并将传入的值赋给它。通过使用 ES6 语法来设置默认参数,当在构造函数constructor( speed = 0 )中没有传入任何值时,我们将speed设置为零。

抽象类

与您在其他语言中可能习惯的类似,在 TypeScript 中,我们可以使用抽象类和方法。抽象类仅用于扩展。不能创建抽象类的实例。定义为抽象的方法在任何子类中都需要实现:

abstract class Starship { 
  constructor( protected speed: number = 0 ) { 

  } 
  abstract speedUp( increment: number ): void; 
} 

class LightFreighter extends Starship { 

  public speedUp( increment: number ): void { 
    this.speed = this.speed + increment; 
  } 
} 

抽象类与接口非常相似,只是一个类可以实现多个接口,但只能扩展一个抽象类。

枚举类型

一次又一次,我们使用常量来定义一组逻辑相关的实体。使用 TypeScript,我们可以声明一个由不可变数据填充的枚举类型,然后通过类型引用整个集合:

const enum Status { 
    NEEDS_PATCH, 
    UP_TO_DATE, 
    NOT_INSTALLED 
} 

function setStatus( status: Status ) { 
  // ... 
} 

setStatus( Status.NEEDS_PATCH ); 

在这里,我们声明了一个类型Status,它接受预定义值之一(NEEDS_PATCHUP_TO_DATENOT_INSTALLED)。函数setStatus期望status参数是Status类型。如果传入任何其他值,TypeScript 会报告错误:

setStatus( "READY" ); 
//  error TS2345: Argument of type '"READY"' is not assignable to parameter of type 'STATUS'. 

或者,我们可以使用字符串字面类型,它指的是一组任何字符串值:

function setStatus( status: "NEEDS_PATCH" | "UP_TO_DATE" | "NOT_INSTALLED" ) { 
  // ... 
} 
setStatus( "NEEDS_PATCH" ); 

联合和交叉类型

到目前为止很有趣,不是吗?那么你对此怎么看:在 TypeScript 中,我们可以同时引用多种类型。例如,我们有两个接口AnakinPadmé,需要一个从它们两个继承的新类型(Luke)。我们可以像这样轻松实现它:

interface Anakin { 
  useLightSaber: () => void; 
  useForce: () => void; 
} 
interface Padmé { 
  leaderSkills: string[]; 
  useGun: () => void; 
} 
type Luke = Anakin & Padmé; 

此外,我们可以在不明确声明类型的情况下进行交集操作:

function joinRebelion( luke: Anakin & Padmé ){   
}

我们还可以定义一个允许任何类型的组的联合类型。你知道jQuery库,对吧?函数jQuery接受各种类型的选择器参数,并返回jQuery实例。如何可能用接口来覆盖它呢?

interface PlainObj { 
  [ key: string ]: string; 
} 
interface JQuery { 
} 

function jQuery( selector: string | Node | Node[] | PlainObj | JQuery ): JQuery { 
  let output: JQuery = {} 
  // ... 
  return output; 
} 

当函数返回依赖于传入类型的类型时,我们可以声明一个描述所有可能用例的接口:

interface CreateButton { 
  ( tagName: "button" ): HTMLButtonElement; 
  ( tagName: "a" ): HTMLAnchorElement; 
} 

实现这个接口的函数接受tagName参数的字符串。如果值是"button",函数返回Button元素。如果是"a",则返回Anchor元素。

可以在规范中找到可用的与 DOM 相关的接口www.w3.org/TR/DOM-Level-2-HTML/html.html

泛型类型

我们刚刚检查的类型是指具体类型组合。此外,TypeScript 支持所谓的泛型类型,它有助于在不同上下文中重用一次创建的接口。例如,如果我们想要一个数据映射的接口,我们可以这样做:

interface NumberDataMap { 
  [ key: string ]: number; 
} 

但是NumberDataMap只接受成员值为数字。假设对于字符串值,我们必须创建一个新的接口,比如StringDataMap。或者,我们可以声明一个泛型DataMap,在引用时设置任意值类型的约束:

interface DataMap<T> { 
  [ key: string ]: T; 
} 

const numberMap: DataMap<number> = { foo: 1, bar: 2 }, 
      stringMap: DataMap<string> = { foo: "foo", bar: "bar" }; 

全局库

是的,TypeScript 确实是一种令人印象深刻的语言,当涉及到编写新代码时。但是对于现有的非 TypeScript 库呢?例如,我们将使用 React 和 Redux 模块。它们是用 JavaScript 编写的,而不是 TypeScript。幸运的是,主流库已经提供了 TypeScript 声明文件。我们可以使用 npm 按模块安装这些文件:

npm i -D @types/react 
npm i -D @types/react-dom 

现在,当我们尝试对任何这些模块进行愚蠢的操作时,我们会立即收到有关问题的通知:

import * as React from "react"; 
import * as ReactDOM from "react-dom"; 

ReactDOM.render( 
  <div></div>, 
  "root" 
); 

在编译或输入时,你会得到错误:

error TS2345: Argument of type '"root"' is not assignable to parameter of type 'Element'. 

公平地说,与其传递给ReactDOM.render的 HTML 元素(例如document.getElementById("root")),我传递了一个字符串作为第二个参数。

然而,老实说,并非每个库都提供了 TypeScript 声明。例如,在RSS 聚合器应用程序中,我将使用feedme库(www.npmjs.com/package/feedme)通过 URL 获取和解析 RSS。不过,该库没有声明文件。幸运的是,我们可以快速创建一个:

feedme.d.ts

declare class FeedMe { 
  new ( flag?: boolean ): NodeJS.WritableStream; 
  on( event: "title", onTitle: ( title: string ) => void): void; 
  on( event: "item", onItem: ( item: any ) => void ): void; 
} 

模块feedme公开了一个类FeedMe,但 TypeScript 并不知道这些模块;它还没有在 TypeScript 范围内声明。因此,我们在feedme.d.ts(declare class FeedMe)中使用环境声明来引入作用域中的新值。我们声明接受boolean类型的可选标志并返回 Node.jsWriteStream对象的类构造函数。我们使用重载来描述函数使用的两种情况。在第一种情况下,它接收"title"字符串作为event,并期望回调处理 RSS 标题。在第二种情况下,它接收"title"事件,然后期望回调处理 RSS 条目。

现在,我们可以从服务中使用新创建的声明文件:

/// <reference path="./feedme" /> 
import http = require( "http" ); 
var FeedMe = require( "feedme" ); 

http.get('http://feeds.feedburner.com/TechCrunch/startups', ( res ) => { 
  const parser = new FeedMe( true ); 
  parser.on( "title", ( title: string ) => { 
    console.log( title ); 
  }); 
  res.pipe( parser ); 
}); 

使用三斜杠指令,我们将feedme.d.ts包含在项目中。完成后,TypeScript 会验证FeedMe是否根据其接口使用。

创建静态原型

我认为,到这一点,我们已经足够了解 TypeScript,可以开始应用程序了。与之前的示例一样,首先我们做的是静态原型。

为应用程序设置开发环境

我们必须为项目设置开发环境。因此,我们专门为它分配一个目录,并将以下清单放在其中:

./package.json

{ 
  "name": "rss-aggregator", 
  "title": "RSS Aggregator", 
  "version": "1.0.0", 
  "main": "./app/main.js", 
  "scripts": { 
    "build": "webpack", 
    "start": "electron .", 
    "dev": "webpack -d --watch"  
  } 
} 

根据任何 Electron 应用程序的要求,我们在main字段中设置了主进程脚本的路径。我们还定义了运行 Webpack 进行构建和监视的脚本命令。我们设置了一个脚本命令来使用 Electron 运行应用程序。现在,我们可以安装依赖项。我们肯定需要 TypeScript,因为我们将使用它来构建应用程序:

npm i -D typescript 

对于打包,我们将使用 Webpack,就像我们为 Chat 和 Screen Capturer 应用程序所做的那样,但是这次,我们不再使用babel-loader,而是使用ts-loader,因为我们的源代码是 TypeScript 语法:

npm i -D webpack 
npm i -D ts-loader 

我们还安装了 Electron 和相关模块,这些模块我们在创建 Chat 应用程序时已经检查过:

npm i -D electron 
npm i -D electron-debug 
npm i -D electron-devtools-installer 

最后,我们安装了 React 声明文件:

npm i -D @types/react 
npm i -D @types/react-dom 

为了访问 Node.js 的接口,我们还安装了相应的声明:

npm i -D @types/node 

现在,我们可以配置 Webpack 了:

./webpack.config.js

const path = require( "path" ); 
module.exports = { 
  entry: "./app/ts/index.tsx", 
  output: { 
    path: path.resolve( __dirname, "./app/build/js/" ), 
    filename: "bundle.js" 
  }, 

  target: "electron-renderer", 
  devtool: "source-map", // enum 
  module: { 
    rules: [ 
      { 
        test: /\.tsx?$/, 
        use: "ts-loader" 
      } 
    ] 
  } 
}; 

在这里,我们将入口脚本设置为app/ts/index.tsx,输出为./app/build/js/bundle.js。我们将 Webpack 目标设置为 Electron(electron-renderer),并启用源映射生成。最后,我们指定了一个规则,使 Webpack 使用ts-loader插件处理任何.ts/.tsx文件。

因此,如果我们请求一个文件,比如require("./path/file.ts")import {member} from "./path/file.ts",Webpack 将在打包期间使用 TypeScript 进行编译。我们可以使用 Webpack 选项resolve使其更加方便:

./webpack.config.js

{ 
... 
resolve: { 
    modules: [ 
      "node_modules", 
      path.resolve(__dirname, "app/ts") 
    ], 

    extensions: [ ".ts", ".tsx", ".js"] 
  }, 
... 
} 

在这里,我们声明 Webpack 会尝试解析遇到的任何模块名,对node_modulesapp/ts目录进行解析。因此,如果我们访问一个模块,我们将得到以下结果:

import {member} from "file.ts" 

根据我们的配置,Webpack 首先检查node_modules/file.ts的存在,然后是app/ts/file.ts。由于我们将.ts扩展名列为可解析的,所以可以从模块名中省略它:

import {member} from "file" 

剩下的就是 TypeScript 的配置:

tsconfig.json

{ 
  "compilerOptions": { 
    "target": "es6", 
    "module": "commonjs", 
    "moduleResolution": "node", 
    "sourceMap": false, 
    "outDir": "../dist/", 
    "jsx": "react" 
  }, 

  "files": [ 
    "./app/ts/index.tsx" 
  ] 
} 

它基本上和我们为 TypeScript 介绍示例创建的配置是一样的,只是这里我们不是指向编译器一个目录,而是明确指向入口脚本。我们还告诉编译器它应该期望 JSX。

React-MDL

之前,在开发 Screen Capturer 时,我们研究了组件库 Material UI。这并不是 React 可用的唯一的 material design 实现。这次,让我们尝试另一个–React MDL (react-mdl.github.io/react-mdl/)。所以,我们安装了该库和相应的声明:

npm i -S react-mdl 
npm i -D @types/react-mdl 

根据文档,我们通过导入来启用库:

import "react-mdl/extra/material.css"; 
import "react-mdl/extra/material.js"; 

哦!哦!Webpack 无法解析 CSS 模块,直到我们相应地进行配置。首先,我们必须告诉 Webpack 在node_modules目录中查找react-mdl/extra/material.cssreact-mdl/extra/material.js

./webpack.config.js 
{ 
... 
resolve: { 
   modules: [ 
        "node_modules", 
        path.resolve(__dirname, "app/ts") 
      ], 
      extensions: [ ".ts", ".tsx", ".js", ".css"] 
  }, 
... 
} 

其次,我们添加了一个规则来处理 CSS,使用css-loader插件:

./webpack.config.js

{ 
... 
module: { 
  rules: [ 
    ... 
    { 
      test: /\.css$/, 
      use: [ 
          "style-loader", 
          "css-loader" 
        ] 
    } 
  ] 
}, 

... 
} 

现在,当遇到import "react-mdl/extra/material.css"时,Webpack 会加载样式并将其嵌入页面中。但在 CSS 内容中,有链接到自定义的.woff字体。我们需要让 Webpack 加载所引用的字体文件:

./webpack.config.js

{ 
... 
module: { 
  rules: [ 
    ... 
    { 
       test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 
       use: { 
         loader: "url-loader", 
         options: { 
           limit: 1000000, 
           mimetype: "application/font-woff" 
         } 
       } 
    } 
  ] 
}, 

... 
} 

现在,我们必须安装上述提到的加载器:

npm i -D css-loader 
npm i -D style-loader 

创建 index.html

在 Electron 应用程序中,我们通常首先处理的是主进程脚本,它基本上创建应用程序窗口。对于这个应用程序,我们不会介绍任何新的概念,所以我们可以重用 Chat 应用程序的main.js

index.html将非常简单:

app/index.html
<!DOCTYPE html>
<html lang="en">
   <head>
      <link rel="stylesheet" href="https://fonts.googleapis.com/icon?
      family=Material+Icons">
     <title>RSS Aggregator</title>
 </head>
   <body>
        <div id="root"></div> 
       <script src="img/bundle.js"></script>
  </body>
</html>

基本上,我们加载了 Google 的 Material Icons 字体并声明了边界元素(div#root)。当然,我们必须加载由 Webpack/TypeScipt 生成的 JavaScript。它位于build/js/bundle.js,就像我们在./webpack.config.js中配置的那样。

接下来,我们组成入口脚本:

./app/ts/index.tsx

import "react-mdl/extra/material.css"; 
import "react-mdl/extra/material.js"; 

import * as React from "react"; 
import * as ReactDOM from "react-dom"; 
import App from "./Containers/App"; 

ReactDOM.render( 
  <App />, 
  document.getElementById( "root" ) 
); 

正如你所看到的,它与我们在屏幕捕捉器静态原型中所拥有的相似,除了导入 React-MDL 资产。至于 TypeScript,在代码中并不真正需要任何更改。然而,现在我们确实为我们使用的模块拥有了类型化接口(./node_modules/@types/react-dom/index.d.ts),这意味着如果我们违反了约束,例如ReactDOM.render,我们会得到一个错误。

创建容器组件

现在让我们创建我们在入口脚本中提到的container组件:

./app/ts/Containers/App.tsx

import { Layout, Content } from "react-mdl"; 
import * as React from "react"; 

import TitleBar from "../Components/TitleBar"; 
import Menu from "../Components/Menu"; 
import Feed from "../Components/Feed"; 

export default class App extends React.Component<{}, {}> { 

  render() { 
    return ( 
      <div className="main-wrapper"> 
        <Layout fixedHeader fixedDrawer> 
          <TitleBar /> 
          <Menu /> 
          <Content> 
            <Feed  /> 
          </Content> 
        </Layout> 
      </div> 
    ); 
  } 
} 

在这里,我们从 React-MDL 库中导入LayoutContent组件。我们使用它们来布局我们的自定义组件TitleBarMenuFeed。根据 React 声明文件(./node_modules/@types/react/index.d.ts),React.Component是一个泛型类型,所以我们必须为状态和属性提供接口React.Component<IState, IProps>。在静态原型中,我们既没有状态也没有属性,所以我们可以使用空类型。

创建 TitleBar 组件

下一个组件将代表标题栏:

./app/ts/Components/TitleBar.tsx

import * as React from "react"; 
import { remote } from "electron"; 
import { Header, Navigation, Icon } from "react-mdl"; 

export default class TitleBar extends React.Component<{}, {}> { 

  private onClose = () => { 
    remote.getCurrentWindow().close(); 
  } 
  render() { 
    return ( 
     <Header  scroll> 
        <Navigation> 
            <a href="#" onClick={this.onClose}><Icon name="close" />
            </a> 
        </Navigation> 
    </Header> 
    ); 
  } 
} 

在这里,我们使用 React MDL 的HeaderNavigationIcon组件来设置外观和感觉,并订阅关闭图标的点击事件。此外,我们导入electron模块的remote对象,并通过使用getCurrentWindow方法访问当前窗口对象。它有一个close方法,我们用它来关闭窗口。

我们的Menu组件将包含聚合源的列表。用户可以使用addremove按钮来管理列表。autorenew按钮用于更新所有源。

创建菜单组件

我们将保持源菜单在 React MDL 的Drawer组件中,它会在宽屏上自动显示,并在较小屏幕上隐藏在汉堡菜单中:

./ts/Components/Menu.tsx

import * as React from "react"; 
import { Drawer, Navigation, Icon, FABButton } from "react-mdl"; 

export default class Menu extends React.Component<{}, {}> { 

  render (){ 

    return ( 
     <Drawer  className="mdl-color--blue-grey-900 mdl-
     color-text--blue-grey-50"> 
        <Navigation className="mdl-color--blue-grey-80"> 
          <a> 
             <Icon name="& #xE0E5;" /> 
             Link title 
          </a> 
        </Navigation> 
        <div className="mdl-layout-spacer"></div> 
        <div className="tools"> 
          <FABButton mini> 
              <Icon name="add" /> 
          </FABButton> 

          <FABButton mini> 
              <Icon name="delete" /> 
          </FABButton> 

          <FABButton mini> 
              <Icon name="autorenew" /> 
          </FABButton> 
        </div> 
      </Drawer> 
    ); 
  } 
} 

创建源组件

最后,我们来处理主要部分,我们将在其中显示活动源内容:

./app/ts/Components/Feed.tsx

import * as React from "react"; 
import { Card, CardTitle, CardActions, Button, CardText } from "react-mdl"; 

export default class Feed extends React.Component<{}, {}> { 
  render(){ 
    return ( 
      <div className="page-content feed-index"> 
        <div className="feed-list"> 

          <Card shadow={0} style={{width: "100%", height: "auto", 
          margin: "auto"}}> 
             <CardTitle expand style={{color: "#fff", backgroundColor: 
             "#46B6AC"}}> 
             Title 
             </CardTitle> 
             <CardText> 
                  Lorem ipsum dolor sit amet, consectetur adipiscing 
                  elit. Cras lobortis, mauris quis mollis porta 
             </CardText> 
             <CardActions border> 
                  <Button colored>Open</Button> 
             </CardActions> 
           </Card> 

        </div> 

        <div className="feed-contents"></div> 
      </div> 
    ); 
  } 
} 

.feed-list容器中,我们显示了 RSS 项的列表,每个都用 React MDL 的Card组件包装。容器.feed-contents是项目内容的占位符。

一切准备就绪。我们可以构建并启动:

npm run build
npm start

输出是:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

使用 SASS 添加自定义样式

看起来,结果 UI 需要额外的样式。我建议我们在 SASS 中编写我们的自定义样式:

./app/sass/app.scss

 .main-wrapper { 
  height: 100vh; 
} 

首先,我们让顶层元素(./app/ts/Containers/App.tsx)始终适应实际的窗口高度。

接下来,我们声明一个变量来固定标题栏的高度,并设置源项和项目内容容器的布局:

./app/sass/app.scss

$headrHeight: 66px; 

.feed-index { 
  display: flex; 
  flex-flow: row nowrap; 
  overflow-y: auto; 
  height: calc(100vh - #{$headrHeight}); 
  &.is-open { 
    overflow-y: hidden; 
    .feed-list { 
      width: 50%; 
    } 
    .feed-contents { 
      width: 50%; 
    } 
  } 
} 
.feed-list { 
  flex: 1 0 auto; 
  width: 100%; 
  transition: width 200ms ease; 
} 
.feed-contents { 
  flex: 1 0 auto; 
  width: 0; 
  transition: width 200ms ease; 
} 

最初,源项容器(.feed-list)的宽度为 100%,而项目内容容器(.feed-contents)被隐藏(width:0)。当父容器(.feed-index)接收到带有is-open类的新状态时,两个子容器会优雅地将宽度移动到50%

最后,我们在菜单组件中布局操作按钮:

./app/sass/app.scss

.tools { 
  height: 60px; 
  display: flex; 
  flex-flow: row nowrap; 
  justify-content: space-between; 
} 

好吧,我们引入了一个新的源类型(SASS),所以我们必须调整 Webpack 配置:

./webpack.config.js

{ 
... 
resolve: { 
   modules: [ 
        "node_modules", 
        path.resolve(__dirname, "app/ts"), 
        path.resolve(__dirname, "app/sass") 
      ], 
      extensions: [ ".ts", ".tsx", ".js", ".scss", ".css"] 
  }, 
... 
} 

现在,Webpack 接受.scss模块名称,并在app/sass中查找源。我们还必须配置 Webpack 来将 SASS 编译为 CSS:

./webpack.config.js

{ 
... 
module: { 
  rules: [ 
    ... 
    { 
      test: /\.scss$/, 
      use: [ 
          "style-loader", 
          "css-loader", 
          "sass-loader" 
        ] 
    } 
  ] 
}, 

... 
} 

在这里,我们确定在解析.scss文件时,Webpack 使用sass-loader插件将 SASS 转换为 CSS,然后使用css-loaderstyle-loader加载生成的 CSS。所以,我们现在缺少一个依赖项 - sass-loader;让我们安装它:

npm i -D sass-loader 

这个模块依赖于node-sass编译器,所以我们也需要它:

npm i -D node-sass 

为什么不检查一下我们得到了什么。所以我们构建并启动:

npm run build
npm start

应用程序现在看起来更好了:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

摘要

在这一章中,我们深入学习了 TypeScript。我们研究了变量声明和参数约束中的基本类型。我们使用接口来处理数组和普通对象。您学会了如何处理函数和类的接口。我们注意到了抽象特性,比如成员可访问性修饰符、参数属性、抽象类和方法。您学会了如何使用枚举类型和字符串字面量来处理组实体。我们研究了如何使用泛型类型重用接口。我们还看到了如何在全局库中安装 TypeScript 声明,以及在没有可用声明时如何编写我们自己的声明。我们开始着手应用程序。因此,我们设置了 Webpack 来查找和处理.ts/.tsx模块,以及加载 CSS 和 Web 字体。我们使用 React MDL 库的组件来创建用户界面。我们通过 SASS 加载器扩展了 Webpack 配置,以处理我们的自定义样式。最终我们得到了一个可工作的静态原型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值