前言
上一节对资源稍微作了处理,接下来把生产环境配置一下,对js
进行按动态加载处理,开发中很多情况下,需要基于路由分包或者对一些比较少用到的库进行分包。
前端配置生产环境
前面webpack.base.js
是把服务端和客户端的相同部分抽离出来,现在既然有了生产环境配置,那么就修改一下webpack.base.js
,把这个作为客户端开发环境和生产环境的相同部分抽离。而服务端,则通过process.env.NODE_ENV
进行判断开发环境和生产环境。
目录结构:
├── webpack // 配置文件夹
│ ├── loader //自定义loader文件夹
│ ├── scripts //node脚本文件夹
│ ├── webpack.base.js // 前端开发环境和生产环境相同部分抽离
│ ├── webpack.client.dev.js // 前端开发环境配置文件
│ ├── webpack.client.pro.js // 前端生产环境配置文件
│ ├── webpack.server.js // 服务端配置文件
安装依赖
$ npm i optimize-css-assets-webpack-plugin webpack-manifest-plugin -D
optimize-css-assets-webpack-plugin
是用来压缩css
文件用的,而webpack-manifest-plugin
是用来生成资源映射表json
用的,像生成环境,前端生成的文件肯定是带着hash
的嘛,那服务端像获得相应的正确文件名,就可以通过这个plugin生成的json获取到。
先用webpack.base.js
把前端开发环境和生产环境的相同配置抽离出来。
module.exports = {
entry: resolvePath('../src/client/index.js'),
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [{
test: /.jsx?$/,
use: 'babel-loader',
exclude: /node_modules/
}, {
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}, {
test: /\.(jpg|png|jpeg)$/,
use: [{
loader: 'url-loader',
options: {
limit: 10240,
name: 'img/[name].[hash:7].[ext]',
esModule: false
}
}]
}]
},
optimization: {
splitChunks: {
cacheGroups: {
libs: {
test: /node_modules/,
chunks: 'initial',
name: 'libs'
}
}
}
},
plugins: [
new webpack.DefinePlugin({
'__isServer': false
}),
new CleanWebpackPlugin()
]
}
那么前端开发环境配置就比较简单了,除却base
部分,就只有mode output plugins
和devtool
。这里devtool
是为了方便开发环境出现错误调试使用,我用的是cheap-module-eval-source-map
。
module.exports = merge(base, {
mode: 'development',
output: {
filename: 'index.js',
path: resolvePath('../dist/client')
},
devtool: 'cheap-module-eval-source-map',
plugins: [
new MiniCssExtractPlugin({
filename: 'index.css'
}),
]
});
至于前端的生产环境,主要就是增加hash
,生产资源映射表,进行css
代码压缩,顺便一提,webpack4
在mode = production
的时候,已经做了很多处理,比如js
压缩,所以我们就可以省去对js
压缩处理步骤了。
module.exports = merge(base, {
mode: 'production',
output: {
filename: 'index-[contentHash:10].js',
path: resolvePath('../dist/client')
},
plugins: [
new MiniCssExtractPlugin({
filename: 'index-[contentHash:10].css'
}),
new webpackManifestPlugin({
fileName: '../mainfest.json', // 这个是fileName,上面的是filename,迷惑
filter: ({ name }) => {
const ext = name.slice(name.lastIndexOf('.'));
return ext === '.js' || ext === '.css';
}
}),
new OptimizeCssAssetsWebpackPlugin()
]
})
webpackManifestPlugin
设置filter
的目的,是为了仅仅生产css
和js
文件的资源映射表,对于其他类型文件,例如img
则已经被正确引用。
app.use(express.static('./dist/client'));
相当于是在dist/client
目录下进行相对路径引用,所以图片ssr
输出没啥问题。
那么现在,前端就可以打包生成文件&资源映射表:
同时,css文件也被压缩辽:
服务端配置生产环境
那么现在对于服务端又有了新的问题,服务端在html
拼接的时候,是该引入什么文件名,即引入index.js
还是index-xxx.js
。
这个其实可以直接通过webpack.definePlugin
设置全局常量,在不同NODE_ENV
,引入不同文件名,即可解决。
在package.json
命令上,设置NODE_ENV=production
,即可。
同时,针对路径,设置alias
,方便引入文件。
const isDev = process.env.NODE_ENV === 'development';
module.exports = {
mode: process.env.NODE_ENV,
target: 'node',
entry: resolvePath('../src/server/index.js'),
output: {
filename: 'index.js',
path: resolvePath('../dist/server')
},
externals: [nodeExternals()],
module: {
rules: [{
test: /.css$/,
loader: './webpack/loader/css-remove-loader'
}, {
test: /.jsx?$/,
use: 'babel-loader',
exclude: /node_modules/
}, {
test: /.(jpg|jpeg|png)$/,
use: {
loader: 'url-loader',
options: {
limit: 10240,
name: 'img/[name].[hash:7].[ext]',
esModule: false
}
}
}]
},
resolve: {
alias: {
'@dist': resolvePath('../dist'),
}
},
plugins: [
new webpack.DefinePlugin({
'__isServer': true,
'__isDev': isDev
}),
new CleanWebpackPlugin()
]
};
服务端webpack
配置文件中,通过process.env.NODE_ENV==='development'
,设置了全局常量__isDev
。那么在html
拼接文件中,就可以通过该全局变量进行判断,是引入index.js
还是引入index-xxx.js
。
export const handleHtml = ({ reactStr, initialData, styles }) => {
const jsKeys = ['libs.js', 'main.js'];
const cssKeys = ['main.css'];
let jsValues = [];
let cssValues = [];
if (__isDev) {
jsValues = ['libs.index.js', 'index.js'];
cssValues = ['index.css'];
} else {
const mainfest = require('@dist/mainfest.json');
jsValues = jsKeys.map(v => mainfest[v]);
cssValues = cssKeys.map(v => mainfest[v]);
};
return `<html>.....`;
};
代码还是看起来,清晰易懂的,就不做解释了。
最后在html中,引入我们的jsValues
和cssValues
,即可。
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
${cssValues.map(v => `<link rel="stylesheet" href="${v}"></link>`).join('')}
</head>
<body>
<div id="root">${reactStr}</div>
<textarea id="textareaSsrData" style="display: none">${initialData}</textarea>
</body>
${jsValues.map(v => `<script type="text/javascript" src="${v}"></script>`).join('')}
</html>`
配置运行脚本&运行
最后,我们在package.json
里,配置我们的前端打包&服务端打包等命令。
{
"client:build": "webpack --config ./webpack/webpack.client.pro.js",
"server:build": "NODE_ENV=production webpack --config ./webpack/webpack.server.js",
"build": "npm run client:build && npm run server:build",
"start": "node ./dist/server/index.js"
}
同时修改之前的server:dev
,毕竟项目改成了传入NODE_ENV
形式。
{
"server:dev": "NODE_ENV=development webpack --watch --config ./webpack/webpack.server.js"
}
顺便一提,如果觉得写&
和&&
挺麻烦的,可以用一些node
任务管理模块,例如:npm-run-all
,script-runner
等等。
那么至此,就可以开始编译,并运行起来。
JS动态加载
js动态加载主要通过import()
方法,该方法使用promise
回调,获取我们要异步加载的模块。被import()
加载的组件,会被达成单独的js
文件 ,但是有一些情况是不会处理成单独文件。
比如在CommonJS
规范下,如果引入了文件的某个组件 ,但是对于其余组件进行动态加载,就会失败,其他组件也已经被打入到了main.js
。这个是因为CommonJS
模块是动态定义的,分析他们比较困难,这也是为什么使用CommonJS
会导致程序包变大,一般解决方法是采用tree sharking
。
安装依赖
$ npm i @babel/{plugin-proposal-class-properties,plugin-syntax-dynamic-import} -D
并添加到.babelrc
里
{
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-class-properties"
]
}
React.lazy
对于页面组件比较少用到的,但是很占js
文件体积的组件,可以采用React.lazy
去异步加载组件。lazy
其实就是对动态导入方式的一种优化方法 ,用法比较简单,没啥好说的。
const Comp = React.lazy(() => import('./components/child'));
const Index = () => {
const [show, setShow] = useState(false);
const click = () => setShow(true);
return (
<div>
<div onClick={click}>page: Orange</div>
{
show ?
<React.Suspense fallback={<div>loading</div>}>
<Comp />
</React.Suspense> : null
}
</div>
)
};
基于路由进行动态加载
对于路由的组件而言,我们应该是传入函数()=>import()
,在组件内部去调用这个函数。在回调中,再去渲染出异步加载的组件。
我这里是采用函数,传入加载函数返回组件,然后把加载函数,作为组件的静态方法,方便于后面服务端ssr
的时候,直接加载出这个组件。
const asyncComp = (load) => {
return class AsyncComp extends React.Component {
constructor(props) {
super(props)
this.state = {
Comp: null
};
};
static _load = load;
asyncLoad = async () => {
const { default: Comp } = await AsyncComp._load();
this.setState({ Comp });
};
componentDidMount() {
if (!this.state.Comp) {
console.log('加载异步组件');
this.asyncLoad();
}
};
render() {
const { Comp } = this.state;
return (
Comp ? <Comp {...this.props} /> : <div>loading</div>
)
};
};
};
那么对于路由配置文件而言,需要进行修改,对于需要异步加载的组件,采用以下修改
import { Fruit } from '../pages/index';
export default [{
component: Fruit,
path: '/',
exact: true,
name: 'Fruits'
}, {
component: asyncComp(() => import('../pages/apple/index')),
path: '/apple',
exact: true,
name: 'Apple'
},//...
]
但是采用以上配置,并没有打出若干个包
网上很多说法是,在webpack
打包的时候,对于.jsx?
文件,是采用babel
去处理,babel
的modules
默认值是CommonJS
。也就是,现在我们的代码是采用ES Module
但是babel
会转换成CommonJS
导致的结果。
但是这里采用的是babel-loader8.x
版本,其实已经不会默认转换成CommonJS
了。
同时如果把后面的异步加载删除,仅仅保留Fruits
,开启tree sharking
后,其余page
组件都会被移除,说明tree sharking
并没有失效。
但是依旧被打包进了mian.js
,说明tree sharking
不能处理这种情况需要采用其他方法。
解决办法也比较直接:
- 在相应目录下,去引入组件
- 使用sideEffects去除代码
直接引入
import Fruit from '../pages/fruit/index';
sideEffects
在webpack4
中可以通过package.json
添加sideEffects
来标记无副作用的文件,那么webpack
就会删除该文件中未使用的代码。
sideEffects
和tree sharking
不太一样,tree sharking
只能移除没有用到的代码成员,而sideEffects
可以完整移除整个模块。
想要使用sideEffects
需要在optimization
里开启sideEffects: true
,当然,production
模式下,默认开启。
{
"sideEffects": [
"./src/client/pages/index.js"
]
}
那么以上两种方法,都可以让webpack
将异步组件,分成单独的一个包。
可以看到,在切换路由的时候,会异步加载js
文件。
但是如果直接访问对应的路由,服务端并没有返回相应组件,而是loading
状态,说明在服务端并没有获取到组件。
因为服务端采用的路由配置是和客户端一样的,但是服务端并不需要异步加载,而是需要直接返回组件。那么需要对路由配置进行处理。
服务端处理动态加载问题
我们需要对路由进行处理,目前路由配置的component
是采用异步加载,那么我们需要获取到静态路由配置。而前面,我们将动态加载函数放入到组件的静态方法里,在服务端我们就可以通过调用组件的静态方法,来获取异步加载的组件。
export const getStaticRoute = async (asyncRoute) => {
const staitcRoute = [];
const len = asyncRoute.length;
for (let i = 0; i < len; i++) {
const item = asyncRoute[i];
staitcRoute.push({
...item
});
if (item.component._load) {
const component = (await item.component._load()).default;
staitcRoute[staitcRoute.length - 1].component = component;
}
};
return staitcRoute;
};
在ssr
中间件里,我们需要缓存这个获取静态路由的结果。在运行时,通过自执行函数, 将静态路由结果保存到变量中。
let staticRoute = [];
(async () => {
staticRoute = await getStaticRoute(routeConfig);
})();
并修改前面的App
组件,将路由数组作为它的参数之一获取。那么在客户端获取到的是含异步组件的路由配置,在服务端则是静态路由配置。
const App = ({ pathname, initialData, routeConfig }) => {
return (
<Fragment>
<Header />
<Switch>
{
routeConfig.map(v => {
const { name, ...rest } = v;
if (pathname === v.path) {
const { component: Component, ..._rest } = rest;
return <Route key={v.path} {..._rest} render={(props) => {
props.initialData = initialData;
return <Component {...props} />
}} />
} else {
return <Route key={v.path} {...rest} />
}
})
}
<Route component={() => <Redirect to="/" />} />
</Switch>
</Fragment>
)
};
那么现在直接访问相应路由,ssr
会返回直接返回组件结果。
但是在浏览器接手后,客户端渲染覆盖了服务端渲染结果。
这是因为,虽然服务端ssr
返回了Banana
组件渲染结果。但是浏览器并没有请求组件分割得到的xx.js
。loading
也就是在加载xx.js
的过程。
那么解决办法就是在客户端渲染前,先进行js
文件请求,在js
文件请求回调中,再去进行渲染。同时去修改路由配置数组,将component
设置成对应组件,那么就不会再去异步加载啦。
const clientRender = () => {
ReactDOM.hydrate(
<BrowserRouter>
<App pathname={pathname} initialData={initialData} routeConfig={routeConfig} />
</BrowserRouter>,
document.getElementById('root')
);
};
const judgeAsync = () => {
const route = getAimComp(routeConfig, pathname);
const asyncLoad = route.component._load;
if (asyncLoad) {
asyncLoad().then(res => {
route.component = res.default; // 修改路由配置数组
clientRender();
});
} else {
clientRender();
}
};
judgeAsync();
那么基于路由进行动态加载就完成辽,完整代码如下。
其他章节: