那些激動人心的 React, Webpack, Babel 的新特性對於我們開發體驗帶來哪些提升

(Webpack 4.0+, React 16.0.0+, Babel 7+)


作者: 趙瑋龍

寫在開頭: 在懷著激動和忐忑的心情寫出團隊第一篇文章時, 這個興奮感一方面來自團隊組建以來這是我們首次對外部開啟一扇窗, 另一方面我們也會持續聽取意見,維持一個交流的心態。

自 React 在 master 分支2017.09.27更新了16.0.0以來, 到至今為止發過多個版本(雖然 fiber 演算法帶來的非同步載入還沒有開放穩定版本 API, 但是不遠啦...)

但是除去這個我們翹首以盼的改變外, 也同樣有很多我們值得一提的東西。

結合 Webpack 4.0, Babel 7 我們會在這裡實現一個基本滿足日常開發需求的前端腳手架

(有亮點哦!! 我們自己實現了我們自己的 react-loadable 和 react-redux 的功能藉助新特性)


我們先從編譯文件開始

我們看看 Babel 7 和 Webpack 4 給我的編譯和構建帶來那些便利。

以往的.babelrc都離不開``babel-preset-es20**``包括``stage-*``等級的配置, 在新的版本里作者覺得這些過於繁瑣, 乾脆直接支持最新版本好啦(可以看看他們的調研和理由)於是我們的 .babelrc 就變成這樣啦:

{ "presets": [ ["@babel/preset-env",{ "modules": false, // 依然是對於webpack的tree-shaking兼容做法 }], "@babel/preset-react", "@babel/preset-stage-0", ], "plugins": [ "@babel/plugin-syntax-dynamic-import" ], }

很容易發現 react 還是需要單獨配置的 stage-0 只有0級的規範啦, 支持新的原生 api 還是需要 syntax-dynamic-import 這個存在。還有個問題可能你也注意到了, 所有 Babel 7 的 Packages 都是這麼寫的(@babel/x), 原因在(這裡) 也有。

再來說說Webpack 4的一些改變

首先說說最大改變可能也是 parcel 出現0配置給本身配置就比較繁瑣的 webpack 更多壓力了,

這回官方破釜沉舟的也推出0配置選項。

使用方式提供 cli 模式, 當然你也可以在配置文件中聲明, 我們後面會指出

webpack --mode production webpack --mode development

那麼這個默認模式里會包含以往哪些配置選項

官網是這麼解釋的

development 環境包含:

  1. 瀏覽器 debugging 的工具(默認設置了 devtool)
  2. 更快的編譯環境周期(設置 cache)
  3. 運行過程中有用的報錯信息

production 環境包含:

  1. 文件輸出大小壓縮( ugliy 處理)
  2. 更快的打包時間
  3. 設置全局環境變數 production
  4. 不暴露源碼和文件路徑
  5. 容易使用的 output 資源(會有很多類似於 hosting 內部代碼編譯後優化默認使用)

(兩種模式甚至於還幫你默認設置了入口 entry 和 output 路徑, 但是為了配置的易讀性和可配置性我們還是留給我們自己設置比較好)

還有一個重要的改變是官方廢棄掉了 CommonsChunkPlugin 這個插件

原因有如下:

  1. 官方認為首先這個 api 不容易理解並且不好用
  2. 並且提取公共文件中含有大量的冗餘代碼
  3. 在做非同步載入的時候這個文件必須每次都首先載入

(這麼看來廢棄也確實理所應當啦!)

取而代之的是現在默認就支持的 code-splitting(只要你採用動態載入的 api => import())

webpack 會默認幫你做代碼拆分並且非同步載入, 並且不受上面提到mode模式的限制(意味著 mode 為 none 也是可以 work 的, 這就是所謂的拆包即用了吧!)

寫法如下:

const Contract = asyncRoute(() => import(./pages/contract), { loading: Loading,})

上面的寫法看起來有點怪, 正常的寫法直接應該是 import 返回一個 promise:

import(/* webpackChunkName: "lodash" */ lodash).then(_ => { var element = document.createElement(div) element.innerHTML = _.join([Hello, webpack], ) return element}).catch(error => An error occurred while loading the component)

但是我們返回的是個 React 的 component, 所以需要做一些處理, 並且在非同步載入的時候因為是發起一次網路請求你可能還會需要一個友好地loading界面(非同步載入的具體細粒度也需要你自己確定, 比較常見的是根據頁面 route 去請求自己的 container 然後載入頁面里的相應 component)

這裡我們自己封裝了這個 asyncRoute, 它的作用除去返回給我們一個正常的 component 之外, 我們還可以給他傳遞一個 loading,用來處理 loading 界面和請求過程中捕獲的 error 信息, 如果我們需要支持 ssr, 還需要給個特殊標記用以做不同的處理, 廢話不多說上代碼如何實現這個 asyncRoute

// 這裡是它的用法// e.x author: zhaoweilong// const someRouteContainer = asyncRoute(() => import(../componet), {// loading: <Loading>loading...</Loading>// })// <Route exact path=/router componet={someRouteContainer} />// function Loading(props) {// if (props.error) {// return <div>Error!</div>;// } else {// return <div>Loading...</div>;// }// }const asyncRoute = (getComponent, opts) => { return class AsyncRoute extends React.Component { static Component = null state = { Component: AsyncRoute.Component, error: null, } componentWillMount() { if (!this.state.Component) { getComponent() .then(module => module.default || module) .then(Component => { AsyncRoute.Component = Component this.setState({ Component }) }) .catch(error => { this.setState({ error }) }) } } render() { const { Component, error } = this.state const loading = opts.loading if (loading && !Component) { return React.createElement(loading, { error, }) } else if (Component) { return <Component {...this.props}/> } return null } }}

(上面的寫法不包含ssr的處理, ssr還要你把這些 component 提前載入好 preload)

說了這麼多。。。還沒說如果我們真正的webpack的配置文件長什麼樣子:

const path = require(path)const HtmlWebpackPlugin = require(html-webpack-plugin)const port = process.env.PORT || 3000module.exports = { target: web, entry: { bundle: [ ./src/index.js, ], }, output: { path: path.resolve(__dirname, dist), filename: [name].js, publicPath: /, }, module: { rules: [ { test: /.js$/, use: babel-loader, exclude: [/node_modules/], }, ], }, mode: development, devtool: cheap-module-source-map, //這裡需要替換掉默認的devtool設置eval為了兼容後面我們提到的react 的ErrorBoundary plugins: [ new HtmlWebpackPlugin( { filename: ./src/index.html, } ), ]}

可以看到我們只用了 HtmlWebpackPlugin 來動態載入編譯過後的文件, entry 和 output 也是因為需要定製化和方便維護性, 我們自己定義配置文件極其簡單,那麼你可能會好奇開發環境簡單, 那麼生產環境呢?

const webpack = require(webpack)const devConfig = require(./webpack.config)const ASSET_PATH = process.env.ASSET_PATH || /static/module.exports = Object.assign(devConfig, { entry: { bundle: ./src/index.js, }, output: Object.assign(devConfig.output, { filename: [name].[chunkhash].js, publicPath: ASSET_PATH, }), module: { rules: [ ...devConfig.module.rules, ] }, mode: production, devtool: none,})

它好像更加簡單啦, 我們只需要對 output 做一些我們需要的定製化, 完全沒有插件選項

看看我們 build 之後文件是什麼樣子的:

可以看到我們除去 bundle 的入口文件之外多了0,1,2三個文件這裡面分別提取了 react 和 index 以及非同步載入的一個路由 contract 相應 js 文件。


搞定配置之後, 來看看激動人心的React新特性以及一些應用

我們著重介紹4個特性並且實戰3個特性

  • 增加 ErrorBoundary 組件 catch 組件錯誤
  • 廢棄 componentWillReceiveProps 更換為 static getDerivedStateFromProps
  • 增加 render props 寫法
  • 新的 context API

我們先介紹下第一個改動, 這裡 React 覺得之前的開發報錯機制過於不人性化了, 所以允許我們在組件外層包裹組件 ErrorBoundary, 而這個自定義的組件會有一個自己的生命周期 componentDidCatch 用來補貨錯誤, 我們廢話不多說來看看代碼:

import React from reactimport styled from styled-componentsconst StyledBoundaryBox = styled.div` background: rgba(0,0,0,0.4); position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; z-index: 2;`const Title = styled.h2` position: relative; padding: 0 10px; font-size: 17px; color: #0070c9; z-index: 1991;`const Details = styled.details` position: relative; padding: 0 10px; color: #bb1d1d; z-index: 1991;`class ErrorBoundary extends React.Component { state = { hasError: false, error: null, errorInfo: null, } componentDidCatch(error, info) { this.setState({ hasError: true, error: error, errorInfo: info, }) } render() { if (this.state.hasError) { return( <StyledBoundaryBox> <Title>頁面可能存在錯誤!</Title> <Details> {this.state.error && this.state.error.toString()} <br/> {this.state.errorInfo.componentStack} </Details> </StyledBoundaryBox> ) } return this.props.children }}export default ErrorBoundary

把它包裹在你想 catch 的組件外層。我直接放到了最外層。當然你可以按照Dan的做法分別catch頁面相應的部分

其實你會發現這個組件非常類似於我們 js 中的 try{}catch{} 代碼塊, 其實確實是 React 希望這樣的開發體驗更佳接近於原生 js 的一種思路。

當有報錯的時候你會發現在詳情中有一個報錯組件的調用棧, 方便你去定位錯誤, 當然報錯的樣式你可以自己定義這裡過於醜陋請忽略!!!

再來看看這個新的生命周期

首先把原先的生命周期從實例屬性更改為class的靜態屬性, 我們能想到最直觀對於我們開發者的影響是我們沒發直接調用 this 了。我們先看下這個 function 長什麼樣子(如果你想詳細了解這個提案)

//以前class ExampleComponent extends React.Component { state = { derivedData: computeDerivedState(this.props) }; componentWillReceiveProps(nextProps) { if (this.props.someValue !== nextProps.someValue) { this.setState({ derivedData: computeDerivedState(nextProps) }); } }}//以後class ExampleComponent extends React.Component { state = {}; static getDerivedStateFromProps(nextProps, prevState) { if (prevState.someMirroredValue !== nextProps.someValue) { return { derivedData: computeDerivedState(nextProps), someMirroredValue: nextProps.someValue }; } return null; } }}

我們發現首先我們不需要在改變的時候 this.setState 了, 而是 return 有改變的部分(這裡就是 setState 的作用), 如果沒有 return null 其他的屬性會依舊保持原來的狀態。

它還有一個作用是之前 cwrp() 沒有的, cwrp() 只在組件 props update 時候更新。

但是新的 gdsfp() 確在首次掛在 inital mount 的時候也會走, 你可能會覺得很奇怪我以前明明習慣使用(this.props 和nextProps)做判斷為何現在非要放到 state 里去判斷呢, 我們可以從這個 api 的名字看出從 state 取得 props 也就是希望你能存一份 props 到 state 如果你需要做對比直接比之前存的和之後可能改變的 nextprops 就好啦, 後面無論是 dispatch(someAction) 還有 return{} 都可以。但是問題是如果我採用 redux 我還要存一份改變的數據在 state 而不是都在全局的 store 中嗎? 這個地方還真是一個非常敏感並且很大的話題(因為它關係到 React 本身發展未來和相對以來這些 redux 包括 react-redux 的未來)如果你感興趣你可以看下包括redux作者Dan和幾位核心成員的討論,很具有啟發性, 當 api 穩定後我們後續文章也會來討論下來它的可能性。如果你持續關注我們!!!

下面我們來說下 render props 這個更新可是讓我個人很興奮的

因為它直接影響到我們在的編程體驗

(這個概念你可以在官網詳細查看)

其實這個概念之前在react-router4中就有體現如果你還記得類似這種寫法:

<Route exact path=/ render={() => <Pstyled>歡迎光臨!</Pstyled>} />

如果這時候你還在用Mixins那貌似我們之間就有點gap了。之前我們談到HOC的實現一般都會想到高階組件, 但是本身它卻有一些弊端(我們來看一下):

(藉助官方一個例子)

import React from reactimport ReactDOM from react-domconst withMouse = (Component) => { return class extends React.Component { state = { x: 0, y: 0 } handleMouseMove = (event) => { this.setState({ x: event.clientX, y: event.clientY }) } render() { return ( <div style={{ height: 100% }} onMouseMove={this.handleMouseMove}> <Component {...this.props} mouse={this.state}/> </div> ) } }}const App = React.createClass({ render() { // Instead of maintaining our own state, // we get the mouse position as a prop! const { x, y } = this.props.mouse return ( <div style={{ height: 100% }}> <h1>The mouse position is ({x}, {y})</h1> </div> ) }})const AppWithMouse = withMouse(App)ReactDOM.render(<AppWithMouse/>, document.getElementById(app))

  • 問題一 是你不知道 hoc 中到底傳遞給你什麼改變了你的props, 如果他還是第三方的。那更是黑盒問題。
  • 問題二 命名衝突, 因為你總會有個函數名這裡叫做 withMouse

那我們看看 render props 如果解決這兩個問題呢?

import React from reactimport ReactDOM from react-domimport PropTypes from prop-types// 我們可以用普通的component來實現hocclass Mouse extends React.Component { static propTypes = { render: PropTypes.func.isRequired } state = { x: 0, y: 0 } handleMouseMove = (event) => { this.setState({ x: event.clientX, y: event.clientY }) } render() { return ( <div style={{ height: 100% }} onMouseMove={this.handleMouseMove}> {this.props.render(this.state)} </div> ) }}const App = React.createClass({ render() { return ( <div style={{ height: 100% }}> <Mouse render={({ x, y }) => ( // 這裡面的傳遞很清晰 <h1>The mouse position is ({x}, {y})</h1> )}/> </div> ) }})ReactDOM.render(<App/>, document.getElementById(app))

是不是覺得無論從傳值到最後的使用都那麼的簡潔如初!!! (最重要的是 this.props.children 也可以用來當函數哦!)

那麼接下來重頭戲啦, 如何用它實現 react-redux, 首先我們都知道 connect()() 就是一個典型的 HOC

下面是我們的實現:

import PropTypes from prop-typesimport React, { Component } from reactconst dummyState = {}class ConnectConsumer extends Component { static propTypes = { context: PropTypes.shape({ dispatch: PropTypes.func.isRequired, getState: PropTypes.func.isRequired, subscribe: PropTypes.func.isRequired, }), children: PropTypes.func.isRequired, } componentDidMount() { const { context } = this.props this.unsubscribe = context.subscribe(() => { this.setState(dummyState) }) } componentWillUnmount() { this.unsubscribe() } render() { const { context } = this.props const passProps = this.props return this.props.children(context.getState(), context.dispatch) }}

是不是很酷那他怎麼用呢? 我們傳遞了 state, dispatch 那它的用法和之前傳遞的方式就類似了而且可能更加直觀。

const ConnectContract = () => ( <Connect> {(state, dispatch, passProps) => { //這裡無論是select還是你想用reselect都沒問題的因為這就是一個function,Do ever you want const { addStars: { num } } = state const props = { num, onAddStar: (...args) => dispatch(addStar(...args)), onReduceStart: (...args) => dispatch(reduceStar(...args)), } return ( <Contract {...props}/> ) }} </Connect>)

你可能會質疑, 等等。。。我們的 <Provider store={store}/> 呢?

來啦來啦, React 16.3.0 新的 context api 我們來試水下

import React, { createContext, Children } from reactexport const StoreContext = createContext({ store: {},})export const ProviderComponent = ({ children, store }) => ( <StoreContext.Provider value={store}> {Children.only(children)} </StoreContext.Provider>)````import { StoreContext } from ./providerconst Connect = ({ children }) => ( <StoreContext.Consumer> {(context) => ( <ConnectConsumer context={context}> {children} </ConnectConsumer> )} </StoreContext.Consumer>)

這就是新的 api 你可能會發現調用方法該了 createContext 生成對象兩個屬性分別是一個 react component 一個叫做 provider 一個叫做 consumer, 你可能好奇為什麼要這麼改, 這裡就不得不提到之前的 context 遇到一些問題, 詳細的原因都在這裡啦

我這裡就不多嘴啦, 但是主要原因我還是要說一下原來的傳遞方式會被 shouldComponentUpdate blocks context changes 會被這個生命周期阻斷更新, 但是新的方法就不會因為你會在你需要的時候 consumer 並且通過我們之前說的 render props 的寫法以參數的形式傳遞給你真正需要用到的子組件。是不是感覺他甚至都不那麼的全局概念了呢?

介紹了這麼多酷酷的東西, 好像我們的新架構也出具模樣啦, 嘿嘿

如果你想嘗試跑一下可以訪問這裡, 歡迎點贊!!

作為最後的總結我們是滴滴 AMC 事業部的前端團隊, 以後會有更多有趣的分享哦, 歡迎關注專欄! 順便劇透下下篇會是 redux 相關主題! (有任何問題麻煩留言交流哦! )

推薦閱讀:

webpack 基礎
iView 發布後台管理系統 iview-admin,沒錯,它就是你想要的
編寫自己的Webpack Loader
webpack技術講解及入門
require,import區別?

TAG:React | webpack | Babel |