目前Web實現矢量渲染的主流技術包括SVG、VML和WebGL。相對而言,VML是一種較古老的技術,雖然未成為W3C標准,但被早期的IE瀏覽器(IE9以下)和微軟Office廣泛使用,目前已經遠離了瀏覽器戰場。所以可供選擇的僅剩SVG和WebGL。SVG是XML的一個子集,秉承了一個標簽對應一條數據的原則,目前經常被使用於數據量較小的web項目,比如圖表和地鐵圖。Web矢量地圖的數據量非常龐大,舉個例子,如下圖所示的一個512px*512px的瓦片,其數據量是一個接近5位數的二維數組。而這個瓦片僅僅是最簡單的大陸和海洋輪廓,同尺寸街道圖的數據量更加龐大。
處理龐大的數據量必然對性能的要求非常苛刻,況且由於中間隔着一層瀏覽器,Web地圖並不能完全發揮CPU的計算能力。在有限的CPU資源下如果能夠借助其他計算資源則必事半功倍,能夠調用GPU資源的WebGL便成為了唯一的選擇。
SVG不適合開發Web矢量地圖的原因主要有兩點:
- 無法借助GPU提高性能;
- Web地圖交互非常頻繁,比如移動、縮放、旋轉等等,如果使用SVG則需要借助頻繁操作DOM實現,而DOM操作是瀏覽器最消耗性能的行為。
技術選型
確定了底層技術-WebGL之后,接下來需要選擇合適的輔助技術,針對目標有兩點:
- JavaScript
- 構建工具
WebGL渲染與CSS無關,所以CSS開發框架的選型對整體的影響微乎其微,在此略過。
WebGL可以理解為OpenGL在瀏覽器環境下的變種,保留了OpenGL ES的語義和規范,提供相對簡潔的JavaScript API。絕大部分的shader可以實現WebGL和OpenGL的共用。開發WebGL shader的語言GLSL是一種語法接近C的強類型編程語言。這一點對於習慣了JavaScript的前端開發者們需要一定的調整。既然是調整,那么不妨調整的徹底一些:將整體開發都引入強類型的概念。目前支持在JavaScript中引入強類型的主流框架有兩種:TypeScript和Flow.js。TypeScript是JavaScript的強類型超集,Flow則更接近於一種類型注解或者注釋工具。相對而言,引入Flow的成本更低,你可以自由決定哪些文件開啟或者關閉類型檢查,僅僅需要在文件頂部添加一行注釋:
// @flow
所以Flow非常適合現有的項目進行遷移,而如果使用TypeScript則更需要將全部源代碼進行改寫。好在目前要做的項目並沒有歷史包袱,所以Flow的這點優勢並不能作為技術選型的決定性因素。
最終選擇TypeScript的原因有以下幾點:
- 語法更嚴謹甚至有些繁瑣,但習慣之后非常順手;
- 生態更豐富,目前大部分主流第三方庫均提供TypeScript支持。
ES6正式推出了Typed Array標准,但其實早在ES6之前,支持WebGL的瀏覽器就已經提供了強類型數組的API,目的是為了提高計算性能。
構建工具的選擇相對比較多,Webpack、Rollup、gulp都是非常優秀的工具。最終選擇Webpack的原因非常簡單:比較熟。
構建配置
Webpack的配置與常規的web項目大體相同,需要注意的兩點是:
- TypeScript與Babel的配合
- shader的構建
TypeScript&Babel
TypeScript本身支持編譯為ES5或ES3,即將tsconfig.json
的編譯選型target
修改為"es5"/"es3"
:
{
"compilerOptions": {
"target": "es5"
}
}
TypeScript編譯器對於語法規范的轉譯功能可以滿足絕大多數ES6新功能,但是其功能的全面性相比較Babel仍然有些不足,所以為了對編譯進行更精准的控制,項目中采用的方案是將TypeScript首先轉譯為ES6語法,再借助Babel將其轉譯為ES5,即將tsconfig.json
中的compilerOptions.target
設置為"es6"
,webpack配置如下:
module: {
rules: [{
test: /\.ts$/,
exclude: /node_modules/,
use: ['babel-loader','awesome-typescript-loader']
}
}
Webpack編譯TypeScript的loader有兩個:ts-loader
和awesome-typescript-loader
。最終選擇后者的原因當然不是因為它的名字中有個awesome
,而是相對於前者,awesome-typescript-loader
能夠提供一些更加便利的功能,比如alias-別名。
如果源碼的目錄結構比較復雜,引用一個模塊時可能需要寫很長的路徑名稱,比如:
import Utils from '../../../utils';
為了令代碼具有更好的易讀性,我們通常借助一些工具將模塊的引用設置較短的別名。Webpack也有此功能,通過resolve
配置模塊的別名:
resolve: {
alias: {
'utils': path.resolve(__dirname,'src/utils')
}
}
但遺憾的是ts-loader
和awesome-typescript-loader
並不能直接使用Webpack的alias配置,源碼中直接使用模塊別名將會拋出not found
錯誤,請注意這個錯誤是TypeScript編譯器拋出而非Webpack。解決方案很簡單:在tsconfig.json
中配置模塊別名。如下:
{
"paths": {
"utils/*": ["src/utils/*"]
}
}
但這並不是最終的解決方案,因為如果使用ts-loader
作為Webpack集成的話,Webpack並不能獲取tsconfig.json
的別名配置,也就是說,Webpack將會拋出not found
錯誤。awesome-typescript-loader
很好地解決了這個問題,它可以將tsconfig.json
的別名配置映射至Webpack的resolve.alias
。當然,如果你仍然堅持使用ts-loader
也可以解決,如果你不怕麻煩的話:在Webpack中手動配置同樣的resolve.alias
。
另外需要注意的是,使用awesome-typescript-loader
需要在Webpack的resolve
中創建對應的插件:
const TsConfigPathsPlugin = require('awesome-typescript-loader').TsConfigPathsPlugin;
module.exports = {
module: {
rules: [{
test: /\.ts$/,
exclude: /node_modules/,
use: ['babel-loader','awesome-typescript-loader']
},
// other rules
]
},
resolve: {
plugins: [
new TsConfigPathsPlugin({
configFileName: Path.resolve(__dirname,'../tsconfig.json')
})
]
}
}
shader
WebGL創建shader的流程為:
- 首先創建指定類型的shader實例;
- 將shader源碼與實例綁定;
- 編譯shader。
示例代碼如下:
const source = `
precision mediump float;
attribute vec2 a_pos;
uniform vec4 u_color;
uniform vec2 u_resolution;
uniform vec2 u_translate;
varying vec4 v_color;
void main() {
vec2 real_poistion = (a_pos+u_translate) / u_resolution * 2.0 - 1.0;
gl_Position = vec4(real_poistion * vec2(1, 1), 0, 1);
v_color = u_color;
}`;
// 創建shader實例
const Shader = gl.createShader(gl.VERTEX_SHADER);
// 綁定shader源碼
gl.shaderSource(Shader,source);
// 編譯
gl.compileShader(Shader);
shader的源碼以字符串的形式綁定至shader實例,也就是說,不論shader的源碼是用什么編程語言編寫(比如可以按照上述代碼中用JavaScript字符串編寫,也可以直接用glsl語言編寫),一定要保證以字符串的形式引入shader源碼模塊。秉承這項原則,最簡單的shader構建方案便是上述代碼中的字符串形式。比如將上述示例代碼中的shader源碼單獨抽離為vertex.js
如下:
export default `
precision mediump float;
attribute vec2 a_pos;
uniform vec4 u_color;
uniform vec2 u_resolution;
uniform vec2 u_translate;
varying vec4 v_color;
void main() {
vec2 real_poistion = (a_pos+u_translate) / u_resolution * 2.0 - 1.0;
gl_Position = vec4(real_poistion * vec2(1, 1), 0, 1);
v_color = u_color;
}`;
然后在主文件中引入:
import VertexShaderSource from './vertex.js';
// 創建shader實例
const Shader = gl.createShader(gl.VERTEX_SHADER);
// 綁定shader源碼
gl.shaderSource(Shader,VertexShaderSource);
// 編譯
gl.compileShader(Shader);
這種書寫方式優點是不需要對Webpack進行任何配置,但是卻等於放棄了IDE對glsl語法的高亮、糾錯等輔助功能。如果shader源碼只有幾行倒也沒什么大問題,但是對於幾十上百行的代碼如果沒有高亮輔助的話可能會嚴重影響開發效率。
解決這個問題的辦法要從兩方面入手:
- 令Webpack能夠正確編譯glsl代碼;
- 令TypeScript能夠將glsl模塊與ts模塊融合。
第一個問題很好解決,因為我們的目的是把glsl模塊引入到js模塊中並且作為字符串使用,所以Webpack要做的就是將glsl源碼構建為字符串即可:
{
test: /\.glsl$/,
loader: 'raw-loader'
}
raw-loader
的功能是將被引入的文件內容轉換為字符串。
除了強類型帶來的開發模式轉變以外,TypeScript最大的問題是不能自動識別ts以外的任何其他類型模塊,即使最普遍的JSON也不行。比如下述代碼在TypeScript環境下會報not found
錯誤:
import Data from '../data.json';
這時候需要用到TypeScript的聲明文件。聲明文件的作用簡單來說就是告知TypeScript編譯器一些必要的信息以便被正確識別。比如聲明一些全局的類型(type)、接口(interface)、模塊(module)等。
默認情況下,TypeScript編譯器會自動識別源碼和node_modules
目錄中@types
文件夾內的聲明文件,你也可以通過配置tsconfig.json
中compilerOptions.typeRoots
指定聲明文件目錄。針對上文提到的TypeScript不識別glsl和json模塊問題,我們在源碼目錄的@types
文件夾中創建聲明文件global.d.ts
,內容如下:
declare module '*.glsl';
declare module '*.json';
declare type WidthAndHeight = {
width: number;
height: number;
};
上述代碼中聲明了三個信息:
- 聲明
glsl
后綴類型的文件為可識別模塊; - 聲明
json
后綴類型的文件為可識別模塊; - 聲明全局類型
WidthAndHeight
,此類型將在任何源碼文件中直接使用。
在以上配置的基礎上還有一個注意事項:與ES6 modules不同的是,TypeScript引入declare
聲明的非ts模塊並不能將其內容自動轉化為默認導出,即export default
。比如在ES6環境下引入一個json文件:
import JsonData from './data.json';
而在TypeScript環境下需要使用以下語法:
import * as JsonData from './data.json';
示例代碼
具體代碼可以參考demo:https://github.com/ihardcoder/demo_ts-webgl-webpack